subtasks alpha version (still in development)

This commit is contained in:
ShayBC 2025-02-22 23:52:13 +02:00
parent 79e5f56ccf
commit c3c3874593
5 changed files with 218 additions and 78 deletions

View file

@ -20,7 +20,7 @@ export const registerCommands = (options: RegisterCommandOptions) => {
const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOptions) => {
return {
"roo-cline.plusButtonClicked": async () => {
await provider.clearTask()
await provider.removeClineFromStack()
await provider.postStateToWebview()
await provider.postMessageToWebview({ type: "action", action: "chatButtonClicked" })
},

View file

@ -75,6 +75,10 @@ type UserContent = Array<
export class Cline {
readonly taskId: string
// a flag that indicated if this Cline instance is a subtask (on finish return control to parent task)
private isSubTask: boolean = false
// a flag that indicated if this Cline instance is paused (waiting for provider to resume it after subtask completion)
private isPaused: boolean = false
api: ApiHandler
private terminalManager: TerminalManager
private urlContentFetcher: UrlContentFetcher
@ -160,6 +164,12 @@ export class Cline {
}
}
// a helper function to set the private member isSubTask to true
// and by that set this Cline instance to be a subtask (on finish return control to parent task)
setSubTask() {
this.isSubTask = true
}
// Add method to update diffStrategy
async updateDiffStrategy(experimentalDiffStrategy?: boolean) {
// If not provided, get from current state
@ -480,6 +490,43 @@ export class Cline {
])
}
async resumePausedTask() {
// release this Cline instance from paused state
this.isPaused = false
// Clear any existing ask state and simulate a completed ask response
// this.askResponse = "messageResponse";
// this.askResponseText = "Sub Task finished Successfully!\nthere is no need to perform this task again, please continue to the next task.";
// this.askResponseImages = undefined;
// this.lastMessageTs = Date.now();
// This adds the completion message to conversation history
await this.say(
"text",
"Sub Task finished Successfully!\nthere is no need to perform this task again, please continue to the next task.",
)
// this.userMessageContent.push({
// type: "text",
// text: `${"Result:\\n\\nSub Task finished Successfully!\nthere is no need to perform this task again, please continue to the next task."}`,
// })
try {
// Resume parent task
await this.ask("resume_task")
} catch (error) {
if (error.message === "Current ask promise was ignored") {
// ignore the ignored promise, since it was performed by launching a subtask and it probably took more then 1 sec,
// also set the didAlreadyUseTool flag to indicate that the tool was already used, and there is no need to relaunch it
this.didAlreadyUseTool = true
} else {
// Handle error appropriately
console.error("Failed to resume task:", error)
throw error
}
}
}
private async resumeTaskFromHistory() {
const modifiedClineMessages = await this.getSavedClineMessages()
@ -2553,10 +2600,12 @@ export class Cline {
const provider = this.providerRef.deref()
if (provider) {
await provider.handleModeSwitch(mode)
await provider.initClineWithTask(message)
await provider.initClineWithSubTask(message)
pushToolResult(
`Successfully created new task in ${targetMode.name} mode with message: ${message}`,
)
// pasue the current task and start the new task
this.isPaused = true
} else {
pushToolResult(
formatResponse.toolError("Failed to create new task: provider not available"),
@ -2648,6 +2697,10 @@ export class Cline {
if (lastMessage && lastMessage.ask !== "command") {
// havent sent a command message yet so first send completion_result then command
await this.say("completion_result", result, undefined, false)
if (this.isSubTask) {
// tell the provider to remove the current subtask and resume the previous task in the stack
this.providerRef.deref()?.finishSubTask()
}
}
// complete command message
@ -2665,6 +2718,10 @@ export class Cline {
commandResult = execCommandResult
} else {
await this.say("completion_result", result, undefined, false)
if (this.isSubTask) {
// tell the provider to remove the current subtask and resume the previous task in the stack
this.providerRef.deref()?.finishSubTask()
}
}
// we already sent completion_result says, an empty string asks relinquishes control over button and field
@ -2740,6 +2797,20 @@ export class Cline {
}
}
// this function checks if this Cline instance is set to pause state and wait for being resumed,
// this is used when a sub-task is launched and the parent task is waiting for it to finish
async waitForResume() {
// wait until isPaused is false
await new Promise<void>((resolve) => {
const interval = setInterval(() => {
if (!this.isPaused) {
clearInterval(interval)
resolve()
}
}, 1000) // TBD: the 1 sec should be added to the settings, also should add a timeout to prevent infinit wait
})
}
async recursivelyMakeClineRequests(
userContent: UserContent,
includeFileDetails: boolean = false,
@ -2779,6 +2850,12 @@ export class Cline {
await this.checkpointSave({ isFirst: true })
}
// in this Cline request loop, we need to check if this cline (Task) instance has been asked to wait
// for a sub-task (it has launched) to finish before continuing
if (this.isPaused) {
await this.waitForResume()
}
// getting verbose details is an expensive operation, it uses globby to top-down build file structure of project which for large projects can take a few seconds
// for the best UX we show a placeholder api_req_started message with a loading spinner as this happens
await this.say(

View file

@ -147,7 +147,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
private disposables: vscode.Disposable[] = []
private view?: vscode.WebviewView | vscode.WebviewPanel
private isViewLaunched = false
private cline?: Cline
private clineStack: Cline[] = []
private workspaceTracker?: WorkspaceTracker
protected mcpHub?: McpHub // Change from private to protected
private latestAnnouncementId = "jan-21-2025-custom-modes" // update to some unique identifier when we add a new announcement
@ -176,6 +176,55 @@ export class ClineProvider implements vscode.WebviewViewProvider {
})
}
// Adds a new Cline instance to clineStack, marking the start of a new task.
// The instance is pushed to the top of the stack (LIFO order).
// When the task is completed, the top instance is removed, reactivating the previous task.
addClineToStack(cline: Cline): void {
this.clineStack.push(cline)
}
// Removes and destroys the top Cline instance (the current finished task), activating the previous one (resuming the parent task).
async removeClineFromStack() {
// pop the top Cline instance from the stack
var clineToBeRemoved = this.clineStack.pop()
if (clineToBeRemoved) {
await clineToBeRemoved.abortTask()
// make sure no reference kept, once promises end it will be garbage collected
clineToBeRemoved = undefined
}
}
// remove the cline object with the received clineId, and all the cline objects bove it in the stack
// for each cline object removed, pop it from the stack, abort the task and set it to undefined
async removeClineWithIdFromStack(clineId: string) {
const index = this.clineStack.findIndex((c) => c.taskId === clineId)
if (index === -1) {
return
}
for (let i = this.clineStack.length - 1; i >= index; i--) {
this.removeClineFromStack()
}
}
// returns the current cline object in the stack (the top one)
// if the stack is empty, returns undefined
getCurrentCline(): Cline | undefined {
if (this.clineStack.length === 0) {
return undefined
}
return this.clineStack[this.clineStack.length - 1]
}
// remove the current task/cline instance (at the top of the stack), ao this task is finished
// and resume the previous task/cline instance (if it exists)
// this is used when a sub task is finished and the parent task needs to be resumed
async finishSubTask() {
// remove the last cline instance from the stack (this is the finished sub task)
await this.removeClineFromStack()
// resume the last cline instance in the stack (if it exists - this is the 'parnt' calling task)
this.getCurrentCline()?.resumePausedTask()
}
/*
VSCode extensions use the disposable pattern to clean up resources when the sidebar/editor tab is closed by the user or system. This applies to event listening, commands, interacting with the UI, etc.
- https://vscode-docs.readthedocs.io/en/stable/extensions/patterns-and-principles/
@ -183,7 +232,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
*/
async dispose() {
this.outputChannel.appendLine("Disposing ClineProvider...")
await this.clearTask()
await this.removeClineFromStack()
this.outputChannel.appendLine("Cleared task")
if (this.view && "dispose" in this.view) {
this.view.dispose()
@ -236,7 +285,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
return false
}
if (visibleProvider.cline) {
// check if there is a cline instance in the stack (if this provider has an active task)
if (visibleProvider.getCurrentCline()) {
return true
}
@ -267,7 +317,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
return
}
if (visibleProvider.cline && command.endsWith("InCurrentTask")) {
if (visibleProvider.getCurrentCline() && command.endsWith("InCurrentTask")) {
await visibleProvider.postMessageToWebview({
type: "invoke",
invoke: "sendMessage",
@ -303,7 +353,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
return
}
if (visibleProvider.cline && command.endsWith("InCurrentTask")) {
if (visibleProvider.getCurrentCline() && command.endsWith("InCurrentTask")) {
await visibleProvider.postMessageToWebview({
type: "invoke",
invoke: "sendMessage",
@ -392,13 +442,21 @@ export class ClineProvider implements vscode.WebviewViewProvider {
)
// if the extension is starting a new session, clear previous task state
this.clearTask()
await this.removeClineFromStack()
this.outputChannel.appendLine("Webview view resolved")
}
// a wrapper that inits a new Cline instance (Task) ans setting it as a sub task of the current task
public async initClineWithSubTask(task?: string, images?: string[]) {
await this.initClineWithTask(task, images)
this.getCurrentCline()?.setSubTask()
}
// when initializing a new task, (not from history but from a tool command new_task) there is no need to remove the previouse task
// since the new task is a sub task of the previous one, and when it finishes it is removed from the stack and the caller is resumed
// in this way we can have a chain of tasks, each one being a sub task of the previous one until the main task is finished
public async initClineWithTask(task?: string, images?: string[]) {
await this.clearTask()
const {
apiConfiguration,
customModePrompts,
@ -413,7 +471,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
const modePrompt = customModePrompts?.[mode] as PromptComponent
const effectiveInstructions = [globalInstructions, modePrompt?.customInstructions].filter(Boolean).join("\n\n")
this.cline = new Cline(
const newCline = new Cline(
this,
apiConfiguration,
effectiveInstructions,
@ -425,10 +483,11 @@ export class ClineProvider implements vscode.WebviewViewProvider {
undefined,
experiments,
)
this.addClineToStack(newCline)
}
public async initClineWithHistoryItem(historyItem: HistoryItem) {
await this.clearTask()
await this.removeClineFromStack()
const {
apiConfiguration,
@ -444,7 +503,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
const modePrompt = customModePrompts?.[mode] as PromptComponent
const effectiveInstructions = [globalInstructions, modePrompt?.customInstructions].filter(Boolean).join("\n\n")
this.cline = new Cline(
const newCline = new Cline(
this,
apiConfiguration,
effectiveInstructions,
@ -456,6 +515,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
historyItem,
experiments,
)
this.addClineToStack(newCline)
}
public async postMessageToWebview(message: ExtensionMessage) {
@ -810,11 +870,15 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.postStateToWebview()
break
case "askResponse":
this.cline?.handleWebviewAskResponse(message.askResponse!, message.text, message.images)
this.getCurrentCline()?.handleWebviewAskResponse(
message.askResponse!,
message.text,
message.images,
)
break
case "clearTask":
// newTask will start a new task with a given task text, while clear task resets the current session and allows for a new task to be started
await this.clearTask()
await this.removeClineFromStack()
await this.postStateToWebview()
break
case "didShowAnnouncement":
@ -826,7 +890,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.postMessageToWebview({ type: "selectedImages", images })
break
case "exportCurrentTask":
const currentTaskId = this.cline?.taskId
const currentTaskId = this.getCurrentCline()?.taskId
if (currentTaskId) {
this.exportTaskWithId(currentTaskId)
}
@ -892,7 +956,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
const result = checkoutDiffPayloadSchema.safeParse(message.payload)
if (result.success) {
await this.cline?.checkpointDiff(result.data)
await this.getCurrentCline()?.checkpointDiff(result.data)
}
break
@ -903,13 +967,13 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.cancelTask()
try {
await pWaitFor(() => this.cline?.isInitialized === true, { timeout: 3_000 })
await pWaitFor(() => this.getCurrentCline()?.isInitialized === true, { timeout: 3_000 })
} catch (error) {
vscode.window.showErrorMessage("Timed out when attempting to restore checkpoint.")
}
try {
await this.cline?.checkpointRestore(result.data)
await this.getCurrentCline()?.checkpointRestore(result.data)
} catch (error) {
vscode.window.showErrorMessage("Failed to restore checkpoint.")
}
@ -1145,42 +1209,43 @@ export class ClineProvider implements vscode.WebviewViewProvider {
)
if (
(answer === "Just this message" || answer === "This and all subsequent messages") &&
this.cline &&
this.getCurrentCline() &&
typeof message.value === "number" &&
message.value
) {
const timeCutoff = message.value - 1000 // 1 second buffer before the message to delete
const messageIndex = this.cline.clineMessages.findIndex(
(msg) => msg.ts && msg.ts >= timeCutoff,
)
const apiConversationHistoryIndex = this.cline.apiConversationHistory.findIndex(
const messageIndex = this.getCurrentCline()!.clineMessages.findIndex(
(msg) => msg.ts && msg.ts >= timeCutoff,
)
const apiConversationHistoryIndex =
this.getCurrentCline()?.apiConversationHistory.findIndex(
(msg) => msg.ts && msg.ts >= timeCutoff,
)
if (messageIndex !== -1) {
const { historyItem } = await this.getTaskWithId(this.cline.taskId)
const { historyItem } = await this.getTaskWithId(this.getCurrentCline()!.taskId)
if (answer === "Just this message") {
// Find the next user message first
const nextUserMessage = this.cline.clineMessages
.slice(messageIndex + 1)
const nextUserMessage = this.getCurrentCline()!
.clineMessages.slice(messageIndex + 1)
.find((msg) => msg.type === "say" && msg.say === "user_feedback")
// Handle UI messages
if (nextUserMessage) {
// Find absolute index of next user message
const nextUserMessageIndex = this.cline.clineMessages.findIndex(
const nextUserMessageIndex = this.getCurrentCline()!.clineMessages.findIndex(
(msg) => msg === nextUserMessage,
)
// Keep messages before current message and after next user message
await this.cline.overwriteClineMessages([
...this.cline.clineMessages.slice(0, messageIndex),
...this.cline.clineMessages.slice(nextUserMessageIndex),
await this.getCurrentCline()!.overwriteClineMessages([
...this.getCurrentCline()!.clineMessages.slice(0, messageIndex),
...this.getCurrentCline()!.clineMessages.slice(nextUserMessageIndex),
])
} else {
// If no next user message, keep only messages before current message
await this.cline.overwriteClineMessages(
this.cline.clineMessages.slice(0, messageIndex),
await this.getCurrentCline()!.overwriteClineMessages(
this.getCurrentCline()!.clineMessages.slice(0, messageIndex),
)
}
@ -1188,30 +1253,36 @@ export class ClineProvider implements vscode.WebviewViewProvider {
if (apiConversationHistoryIndex !== -1) {
if (nextUserMessage && nextUserMessage.ts) {
// Keep messages before current API message and after next user message
await this.cline.overwriteApiConversationHistory([
...this.cline.apiConversationHistory.slice(
await this.getCurrentCline()!.overwriteApiConversationHistory([
...this.getCurrentCline()!.apiConversationHistory.slice(
0,
apiConversationHistoryIndex,
),
...this.cline.apiConversationHistory.filter(
...this.getCurrentCline()!.apiConversationHistory.filter(
(msg) => msg.ts && msg.ts >= nextUserMessage.ts,
),
])
} else {
// If no next user message, keep only messages before current API message
await this.cline.overwriteApiConversationHistory(
this.cline.apiConversationHistory.slice(0, apiConversationHistoryIndex),
await this.getCurrentCline()!.overwriteApiConversationHistory(
this.getCurrentCline()!.apiConversationHistory.slice(
0,
apiConversationHistoryIndex,
),
)
}
}
} else if (answer === "This and all subsequent messages") {
// Delete this message and all that follow
await this.cline.overwriteClineMessages(
this.cline.clineMessages.slice(0, messageIndex),
await this.getCurrentCline()!.overwriteClineMessages(
this.getCurrentCline()!.clineMessages.slice(0, messageIndex),
)
if (apiConversationHistoryIndex !== -1) {
await this.cline.overwriteApiConversationHistory(
this.cline.apiConversationHistory.slice(0, apiConversationHistoryIndex),
await this.getCurrentCline()!.overwriteApiConversationHistory(
this.getCurrentCline()!.apiConversationHistory.slice(
0,
apiConversationHistoryIndex,
),
)
}
}
@ -1481,8 +1552,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.updateGlobalState("experiments", updatedExperiments)
// Update diffStrategy in current Cline instance if it exists
if (message.values[EXPERIMENT_IDS.DIFF_STRATEGY] !== undefined && this.cline) {
await this.cline.updateDiffStrategy(
if (message.values[EXPERIMENT_IDS.DIFF_STRATEGY] !== undefined && this.getCurrentCline()) {
await this.getCurrentCline()!.updateDiffStrategy(
Experiments.isEnabled(updatedExperiments, EXPERIMENT_IDS.DIFF_STRATEGY),
)
}
@ -1724,25 +1795,25 @@ export class ClineProvider implements vscode.WebviewViewProvider {
this.updateGlobalState("requestyModelInfo", requestyModelInfo),
this.updateGlobalState("modelTemperature", modelTemperature),
])
if (this.cline) {
this.cline.api = buildApiHandler(apiConfiguration)
if (this.getCurrentCline()) {
this.getCurrentCline()!.api = buildApiHandler(apiConfiguration)
}
}
async cancelTask() {
if (this.cline) {
const { historyItem } = await this.getTaskWithId(this.cline.taskId)
this.cline.abortTask()
if (this.getCurrentCline()) {
const { historyItem } = await this.getTaskWithId(this.getCurrentCline()!.taskId)
this.getCurrentCline()!.abortTask()
await pWaitFor(
() =>
this.cline === undefined ||
this.cline.isStreaming === false ||
this.cline.didFinishAbortingStream ||
this.getCurrentCline()! === undefined ||
this.getCurrentCline()!.isStreaming === false ||
this.getCurrentCline()!.didFinishAbortingStream ||
// If only the first chunk is processed, then there's no
// need to wait for graceful abort (closes edits, browser,
// etc).
this.cline.isWaitingForFirstChunk,
this.getCurrentCline()!.isWaitingForFirstChunk,
{
timeout: 3_000,
},
@ -1750,11 +1821,11 @@ export class ClineProvider implements vscode.WebviewViewProvider {
console.error("Failed to abort task")
})
if (this.cline) {
if (this.getCurrentCline()) {
// 'abandoned' will prevent this Cline instance from affecting
// future Cline instances. This may happen if its hanging on a
// streaming request.
this.cline.abandoned = true
this.getCurrentCline()!.abandoned = true
}
// Clears task again, so we need to abortTask manually above.
@ -1765,8 +1836,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
async updateCustomInstructions(instructions?: string) {
// User may be clearing the field
await this.updateGlobalState("customInstructions", instructions || undefined)
if (this.cline) {
this.cline.customInstructions = instructions || undefined
if (this.getCurrentCline()) {
this.getCurrentCline()!.customInstructions = instructions || undefined
}
await this.postStateToWebview()
}
@ -1980,8 +2051,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.updateGlobalState("apiProvider", openrouter)
await this.storeSecret("openRouterApiKey", apiKey)
await this.postStateToWebview()
if (this.cline) {
this.cline.api = buildApiHandler({ apiProvider: openrouter, openRouterApiKey: apiKey })
if (this.getCurrentCline()) {
this.getCurrentCline()!.api = buildApiHandler({ apiProvider: openrouter, openRouterApiKey: apiKey })
}
// await this.postMessageToWebview({ type: "action", action: "settingsButtonClicked" }) // bad ux if user is on welcome
}
@ -2012,8 +2083,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.updateGlobalState("apiProvider", glama)
await this.storeSecret("glamaApiKey", apiKey)
await this.postStateToWebview()
if (this.cline) {
this.cline.api = buildApiHandler({
if (this.getCurrentCline()) {
this.getCurrentCline()!.api = buildApiHandler({
apiProvider: glama,
glamaApiKey: apiKey,
})
@ -2295,7 +2366,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
async showTaskWithId(id: string) {
if (id !== this.cline?.taskId) {
if (id !== this.getCurrentCline()?.taskId) {
// non-current task
const { historyItem } = await this.getTaskWithId(id)
await this.initClineWithHistoryItem(historyItem) // clears existing task
@ -2309,8 +2380,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
async deleteTaskWithId(id: string) {
if (id === this.cline?.taskId) {
await this.clearTask()
if (id === this.getCurrentCline()?.taskId) {
await this.removeClineWithIdFromStack(id)
}
const { taskDirPath, apiConversationHistoryFilePath, uiMessagesFilePath } = await this.getTaskWithId(id)
@ -2434,10 +2505,10 @@ export class ClineProvider implements vscode.WebviewViewProvider {
alwaysAllowMcp: alwaysAllowMcp ?? false,
alwaysAllowModeSwitch: alwaysAllowModeSwitch ?? false,
uriScheme: vscode.env.uriScheme,
currentTaskItem: this.cline?.taskId
? (taskHistory || []).find((item) => item.id === this.cline?.taskId)
currentTaskItem: this.getCurrentCline()?.taskId
? (taskHistory || []).find((item) => item.id === this.getCurrentCline()?.taskId)
: undefined,
clineMessages: this.cline?.clineMessages || [],
clineMessages: this.getCurrentCline()?.clineMessages || [],
taskHistory: (taskHistory || [])
.filter((item: HistoryItem) => item.ts && item.task)
.sort((a: HistoryItem, b: HistoryItem) => b.ts - a.ts),
@ -2472,11 +2543,6 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
}
async clearTask() {
this.cline?.abortTask()
this.cline = undefined // removes reference to it, so once promises end it will be garbage collected
}
// Caching mechanism to keep track of webview messages + API conversation history per provider instance
/*
@ -2914,10 +2980,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
await this.configManager.resetAllConfigs()
await this.customModesManager.resetCustomModes()
if (this.cline) {
this.cline.abortTask()
this.cline = undefined
}
await this.removeClineFromStack()
await this.postStateToWebview()
await this.postMessageToWebview({ type: "action", action: "chatButtonClicked" })
}
@ -2935,7 +2998,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
get messages() {
return this.cline?.clineMessages || []
return this.getCurrentCline()?.clineMessages || []
}
// Add public getter

View file

@ -381,7 +381,7 @@ describe("ClineProvider", () => {
// @ts-ignore - accessing private property for testing
provider.cline = { abortTask: mockAbortTask }
await provider.clearTask()
await provider.removeClineFromStack()
expect(mockAbortTask).toHaveBeenCalled()
// @ts-ignore - accessing private property for testing

View file

@ -15,7 +15,7 @@ export function createClineAPI(outputChannel: vscode.OutputChannel, sidebarProvi
startNewTask: async (task?: string, images?: string[]) => {
outputChannel.appendLine("Starting new task")
await sidebarProvider.clearTask()
await sidebarProvider.removeClineFromStack()
await sidebarProvider.postStateToWebview()
await sidebarProvider.postMessageToWebview({ type: "action", action: "chatButtonClicked" })
await sidebarProvider.postMessageToWebview({