Skip to content

Commit 8b9303c

Browse files
roomote[bot]roomotehannesrudolphdaniel-lxs
authored
feat: make task mode sticky to task (RooCodeInc#6177)
Co-authored-by: Roo Code <[email protected]> Co-authored-by: hannesrudolph <[email protected]> Co-authored-by: Daniel Riccio <[email protected]> Co-authored-by: Daniel <[email protected]>
1 parent e3a8e03 commit 8b9303c

File tree

7 files changed

+1687
-7
lines changed

7 files changed

+1687
-7
lines changed

packages/types/src/history.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export const historyItemSchema = z.object({
1616
totalCost: z.number(),
1717
size: z.number().optional(),
1818
workspace: z.string().optional(),
19+
mode: z.string().optional(),
1920
})
2021

2122
export type HistoryItem = z.infer<typeof historyItemSchema>

src/core/task-persistence/taskMetadata.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export type TaskMetadataOptions = {
1818
taskNumber: number
1919
globalStoragePath: string
2020
workspace: string
21+
mode?: string
2122
}
2223

2324
export async function taskMetadata({
@@ -26,6 +27,7 @@ export async function taskMetadata({
2627
taskNumber,
2728
globalStoragePath,
2829
workspace,
30+
mode,
2931
}: TaskMetadataOptions) {
3032
const taskDir = await getTaskDirectoryPath(globalStoragePath, taskId)
3133

@@ -92,6 +94,7 @@ export async function taskMetadata({
9294
totalCost: tokenUsage.totalCost,
9395
size: taskDirSize,
9496
workspace,
97+
mode,
9598
}
9699

97100
return { historyItem, tokenUsage }

src/core/task/Task.ts

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,49 @@ export class Task extends EventEmitter<ClineEvents> {
137137
readonly parentTask: Task | undefined = undefined
138138
readonly taskNumber: number
139139
readonly workspacePath: string
140+
/**
141+
* The mode associated with this task. Persisted across sessions
142+
* to maintain user context when reopening tasks from history.
143+
*
144+
* ## Lifecycle
145+
*
146+
* ### For new tasks:
147+
* 1. Initially `undefined` during construction
148+
* 2. Asynchronously initialized from provider state via `initializeTaskMode()`
149+
* 3. Falls back to `defaultModeSlug` if provider state is unavailable
150+
*
151+
* ### For history items:
152+
* 1. Immediately set from `historyItem.mode` during construction
153+
* 2. Falls back to `defaultModeSlug` if mode is not stored in history
154+
*
155+
* ## Important
156+
* This property should NOT be accessed directly until `taskModeReady` promise resolves.
157+
* Use `getTaskMode()` for async access or `taskMode` getter for sync access after initialization.
158+
*
159+
* @private
160+
* @see {@link getTaskMode} - For safe async access
161+
* @see {@link taskMode} - For sync access after initialization
162+
* @see {@link waitForModeInitialization} - To ensure initialization is complete
163+
*/
164+
private _taskMode: string | undefined
165+
166+
/**
167+
* Promise that resolves when the task mode has been initialized.
168+
* This ensures async mode initialization completes before the task is used.
169+
*
170+
* ## Purpose
171+
* - Prevents race conditions when accessing task mode
172+
* - Ensures provider state is properly loaded before mode-dependent operations
173+
* - Provides a synchronization point for async initialization
174+
*
175+
* ## Resolution timing
176+
* - For history items: Resolves immediately (sync initialization)
177+
* - For new tasks: Resolves after provider state is fetched (async initialization)
178+
*
179+
* @private
180+
* @see {@link waitForModeInitialization} - Public method to await this promise
181+
*/
182+
private taskModeReady: Promise<void>
140183

141184
providerRef: WeakRef<ClineProvider>
142185
private readonly globalStoragePath: string
@@ -268,9 +311,16 @@ export class Task extends EventEmitter<ClineEvents> {
268311
this.parentTask = parentTask
269312
this.taskNumber = taskNumber
270313

314+
// Store the task's mode when it's created
315+
// For history items, use the stored mode; for new tasks, we'll set it after getting state
271316
if (historyItem) {
317+
this._taskMode = historyItem.mode || defaultModeSlug
318+
this.taskModeReady = Promise.resolve()
272319
TelemetryService.instance.captureTaskRestarted(this.taskId)
273320
} else {
321+
// For new tasks, don't set the mode yet - wait for async initialization
322+
this._taskMode = undefined
323+
this.taskModeReady = this.initializeTaskMode(provider)
274324
TelemetryService.instance.captureTaskCreated(this.taskId)
275325
}
276326

@@ -307,6 +357,129 @@ export class Task extends EventEmitter<ClineEvents> {
307357
}
308358
}
309359

360+
/**
361+
* Initialize the task mode from the provider state.
362+
* This method handles async initialization with proper error handling.
363+
*
364+
* ## Flow
365+
* 1. Attempts to fetch the current mode from provider state
366+
* 2. Sets `_taskMode` to the fetched mode or `defaultModeSlug` if unavailable
367+
* 3. Handles errors gracefully by falling back to default mode
368+
* 4. Logs any initialization errors for debugging
369+
*
370+
* ## Error handling
371+
* - Network failures when fetching provider state
372+
* - Provider not yet initialized
373+
* - Invalid state structure
374+
*
375+
* All errors result in fallback to `defaultModeSlug` to ensure task can proceed.
376+
*
377+
* @private
378+
* @param provider - The ClineProvider instance to fetch state from
379+
* @returns Promise that resolves when initialization is complete
380+
*/
381+
private async initializeTaskMode(provider: ClineProvider): Promise<void> {
382+
try {
383+
const state = await provider.getState()
384+
this._taskMode = state?.mode || defaultModeSlug
385+
} catch (error) {
386+
// If there's an error getting state, use the default mode
387+
this._taskMode = defaultModeSlug
388+
// Use the provider's log method for better error visibility
389+
const errorMessage = `Failed to initialize task mode: ${error instanceof Error ? error.message : String(error)}`
390+
provider.log(errorMessage)
391+
}
392+
}
393+
394+
/**
395+
* Wait for the task mode to be initialized before proceeding.
396+
* This method ensures that any operations depending on the task mode
397+
* will have access to the correct mode value.
398+
*
399+
* ## When to use
400+
* - Before accessing mode-specific configurations
401+
* - When switching between tasks with different modes
402+
* - Before operations that depend on mode-based permissions
403+
*
404+
* ## Example usage
405+
* ```typescript
406+
* // Wait for mode initialization before mode-dependent operations
407+
* await task.waitForModeInitialization();
408+
* const mode = task.taskMode; // Now safe to access synchronously
409+
*
410+
* // Or use with getTaskMode() for a one-liner
411+
* const mode = await task.getTaskMode(); // Internally waits for initialization
412+
* ```
413+
*
414+
* @returns Promise that resolves when the task mode is initialized
415+
* @public
416+
*/
417+
public async waitForModeInitialization(): Promise<void> {
418+
return this.taskModeReady
419+
}
420+
421+
/**
422+
* Get the task mode asynchronously, ensuring it's properly initialized.
423+
* This is the recommended way to access the task mode as it guarantees
424+
* the mode is available before returning.
425+
*
426+
* ## Async behavior
427+
* - Internally waits for `taskModeReady` promise to resolve
428+
* - Returns the initialized mode or `defaultModeSlug` as fallback
429+
* - Safe to call multiple times - subsequent calls return immediately if already initialized
430+
*
431+
* ## Example usage
432+
* ```typescript
433+
* // Safe async access
434+
* const mode = await task.getTaskMode();
435+
* console.log(`Task is running in ${mode} mode`);
436+
*
437+
* // Use in conditional logic
438+
* if (await task.getTaskMode() === 'architect') {
439+
* // Perform architect-specific operations
440+
* }
441+
* ```
442+
*
443+
* @returns Promise resolving to the task mode string
444+
* @public
445+
*/
446+
public async getTaskMode(): Promise<string> {
447+
await this.taskModeReady
448+
return this._taskMode || defaultModeSlug
449+
}
450+
451+
/**
452+
* Get the task mode synchronously. This should only be used when you're certain
453+
* that the mode has already been initialized (e.g., after waitForModeInitialization).
454+
*
455+
* ## When to use
456+
* - In synchronous contexts where async/await is not available
457+
* - After explicitly waiting for initialization via `waitForModeInitialization()`
458+
* - In event handlers or callbacks where mode is guaranteed to be initialized
459+
*
460+
* ## Example usage
461+
* ```typescript
462+
* // After ensuring initialization
463+
* await task.waitForModeInitialization();
464+
* const mode = task.taskMode; // Safe synchronous access
465+
*
466+
* // In an event handler after task is started
467+
* task.on('taskStarted', () => {
468+
* console.log(`Task started in ${task.taskMode} mode`); // Safe here
469+
* });
470+
* ```
471+
*
472+
* @throws {Error} If the mode hasn't been initialized yet
473+
* @returns The task mode string
474+
* @public
475+
*/
476+
public get taskMode(): string {
477+
if (this._taskMode === undefined) {
478+
throw new Error("Task mode accessed before initialization. Use getTaskMode() or wait for taskModeReady.")
479+
}
480+
return this._taskMode
481+
}
482+
310483
static create(options: TaskOptions): [Task, Promise<void>] {
311484
const instance = new Task({ ...options, startTask: false })
312485
const { images, task, historyItem } = options
@@ -411,6 +584,7 @@ export class Task extends EventEmitter<ClineEvents> {
411584
taskNumber: this.taskNumber,
412585
globalStoragePath: this.globalStoragePath,
413586
workspace: this.cwd,
587+
mode: this._taskMode || defaultModeSlug, // Use the task's own mode, not the current provider mode
414588
})
415589

416590
this.emit("taskTokenUsageUpdated", this.taskId, tokenUsage)

src/core/tools/newTaskTool.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -80,17 +80,19 @@ export async function newTaskTool(
8080
// Preserve the current mode so we can resume with it later.
8181
cline.pausedModeSlug = (await provider.getState()).mode ?? defaultModeSlug
8282

83-
// Switch mode first, then create new task instance.
84-
await provider.handleModeSwitch(mode)
85-
86-
// Delay to allow mode change to take effect before next tool is executed.
87-
await delay(500)
88-
83+
// Create new task instance first (this preserves parent's current mode in its history)
8984
const newCline = await provider.initClineWithTask(unescapedMessage, undefined, cline)
9085
if (!newCline) {
9186
pushToolResult(t("tools:newTask.errors.policy_restriction"))
9287
return
9388
}
89+
90+
// Now switch the newly created task to the desired mode
91+
await provider.handleModeSwitch(mode)
92+
93+
// Delay to allow mode change to take effect
94+
await delay(500)
95+
9496
cline.emit("taskSpawned", newCline.taskId)
9597

9698
pushToolResult(`Successfully created new task in ${targetMode.name} mode with message: ${unescapedMessage}`)

src/core/webview/ClineProvider.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ import { findLast } from "../../shared/array"
4040
import { supportPrompt } from "../../shared/support-prompt"
4141
import { GlobalFileNames } from "../../shared/globalFileNames"
4242
import { ExtensionMessage, MarketplaceInstalledMetadata } from "../../shared/ExtensionMessage"
43-
import { Mode, defaultModeSlug } from "../../shared/modes"
43+
import { Mode, defaultModeSlug, getModeBySlug } from "../../shared/modes"
4444
import { experimentDefault, experiments, EXPERIMENT_IDS } from "../../shared/experiments"
4545
import { formatLanguage } from "../../shared/language"
4646
import { DEFAULT_WRITE_DELAY_MS } from "@roo-code/types"
@@ -578,6 +578,49 @@ export class ClineProvider
578578
public async initClineWithHistoryItem(historyItem: HistoryItem & { rootTask?: Task; parentTask?: Task }) {
579579
await this.removeClineFromStack()
580580

581+
// If the history item has a saved mode, restore it and its associated API configuration
582+
if (historyItem.mode) {
583+
// Validate that the mode still exists
584+
const customModes = await this.customModesManager.getCustomModes()
585+
const modeExists = getModeBySlug(historyItem.mode, customModes) !== undefined
586+
587+
if (!modeExists) {
588+
// Mode no longer exists, fall back to default mode
589+
this.log(
590+
`Mode '${historyItem.mode}' from history no longer exists. Falling back to default mode '${defaultModeSlug}'.`,
591+
)
592+
historyItem.mode = defaultModeSlug
593+
}
594+
595+
await this.updateGlobalState("mode", historyItem.mode)
596+
597+
// Load the saved API config for the restored mode if it exists
598+
const savedConfigId = await this.providerSettingsManager.getModeConfigId(historyItem.mode)
599+
const listApiConfig = await this.providerSettingsManager.listConfig()
600+
601+
// Update listApiConfigMeta first to ensure UI has latest data
602+
await this.updateGlobalState("listApiConfigMeta", listApiConfig)
603+
604+
// If this mode has a saved config, use it
605+
if (savedConfigId) {
606+
const profile = listApiConfig.find(({ id }) => id === savedConfigId)
607+
608+
if (profile?.name) {
609+
try {
610+
await this.activateProviderProfile({ name: profile.name })
611+
} catch (error) {
612+
// Log the error but continue with task restoration
613+
this.log(
614+
`Failed to restore API configuration for mode '${historyItem.mode}': ${
615+
error instanceof Error ? error.message : String(error)
616+
}. Continuing with default configuration.`,
617+
)
618+
// The task will continue with the current/default configuration
619+
}
620+
}
621+
}
622+
}
623+
581624
const {
582625
apiConfiguration,
583626
diffEnabled: enableDiff,
@@ -807,6 +850,31 @@ export class ClineProvider
807850
if (cline) {
808851
TelemetryService.instance.captureModeSwitch(cline.taskId, newMode)
809852
cline.emit("taskModeSwitched", cline.taskId, newMode)
853+
854+
// Store the current mode in case we need to rollback
855+
const previousMode = (cline as any)._taskMode
856+
857+
try {
858+
// Update the task history with the new mode first
859+
const history = this.getGlobalState("taskHistory") ?? []
860+
const taskHistoryItem = history.find((item) => item.id === cline.taskId)
861+
if (taskHistoryItem) {
862+
taskHistoryItem.mode = newMode
863+
await this.updateTaskHistory(taskHistoryItem)
864+
}
865+
866+
// Only update the task's mode after successful persistence
867+
;(cline as any)._taskMode = newMode
868+
} catch (error) {
869+
// If persistence fails, log the error but don't update the in-memory state
870+
this.log(
871+
`Failed to persist mode switch for task ${cline.taskId}: ${error instanceof Error ? error.message : String(error)}`,
872+
)
873+
874+
// Optionally, we could emit an event to notify about the failure
875+
// This ensures the in-memory state remains consistent with persisted state
876+
throw error
877+
}
810878
}
811879

812880
await this.updateGlobalState("mode", newMode)

0 commit comments

Comments
 (0)