mirror of
https://github.com/cline/cline.git
synced 2026-09-16 21:01:52 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba464b2845 |
@@ -127,3 +127,32 @@ const isGenerating = explanationInfo.status === "generating" && !wasCancelled
|
||||
**See also:** `BrowserSessionRow.tsx` uses similar pattern with `isLastApiReqInterrupted` and `isLastMessageResume`.
|
||||
|
||||
**Backend side:** When streaming is cancelled, clean up properly (close tabs, clear comments, etc.) by checking `taskState.abort` after the streaming function returns.
|
||||
|
||||
## Parallel Command Execution
|
||||
When multiple `execute_command` tools run in parallel (via `PARALLEL_SAFE_TOOLS`), commands cannot use `ask()` for interactive output handling—multiple concurrent `ask()` calls cause conflicts because they share single state variables in TaskState.
|
||||
|
||||
**The Solution - Queue-Based Ask/Response System:**
|
||||
- `PendingAskQueue` (`src/core/task/PendingAskQueue.ts`) - Manages concurrent ask operations with unique IDs per ask
|
||||
- `TaskState.pendingAskQueue` - Replaces single `askResponse`/`askResponseText`/`askResponseImages`/`askResponseFiles`
|
||||
- `Task.ask()` - Refactored to create unique ask IDs and wait for specific responses
|
||||
- `Task.handleWebviewAskResponse()` - Resolves asks in FIFO order (first pending ask gets the response)
|
||||
|
||||
**Concurrent Command Orchestration:**
|
||||
- `ConcurrentCommandOrchestrator` (`src/integrations/terminal/ConcurrentCommandOrchestrator.ts`) - Alternative to `CommandOrchestrator`
|
||||
- Does NOT call `ask()` on each output chunk—streams via `say()` instead
|
||||
- No "Proceed While Running" button (not needed for parallel execution)
|
||||
- Used when `taskState.isExecutingInParallel = true`
|
||||
|
||||
**How It Works:**
|
||||
1. When parallel tools execute, flags are set: `taskState.isExecutingInParallel = true` and `commandExecutor.setParallelExecution(true)`
|
||||
2. Each command's orchestrator is selected based on these flags
|
||||
3. In parallel mode, output is streamed directly without asking for user input on each chunk
|
||||
4. Single user response (if needed) is queued and distributed to waiting asks via `PendingAskQueue`
|
||||
5. Flags are cleared in a `finally` block to ensure proper cleanup
|
||||
|
||||
**Key Files:**
|
||||
- `src/core/task/PendingAskQueue.ts` - Queue implementation
|
||||
- `src/integrations/terminal/ConcurrentCommandOrchestrator.ts` - Parallel-safe orchestrator
|
||||
- `src/core/task/TaskState.ts` - Added `pendingAskQueue` and `isExecutingInParallel`
|
||||
- `src/core/task/index.ts` - Refactored `ask()`, `handleWebviewAskResponse()`, and parallel execution logic
|
||||
- `src/integrations/terminal/CommandExecutor.ts` - Added `setParallelExecution()` and orchestrator selection
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { ClineAskResponse } from "@shared/WebviewMessage"
|
||||
|
||||
/**
|
||||
* Represents a pending ask operation waiting for user response
|
||||
*/
|
||||
interface PendingAsk {
|
||||
askId: string
|
||||
askTs: number
|
||||
response?: ClineAskResponse
|
||||
text?: string
|
||||
images?: string[]
|
||||
files?: string[]
|
||||
resolved: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages concurrent ask operations with queue-based tracking.
|
||||
* Each ask gets a unique ID, allowing multiple asks to be pending simultaneously.
|
||||
* Responses are matched back to their corresponding asks.
|
||||
*/
|
||||
export class PendingAskQueue {
|
||||
private queue: Map<string, PendingAsk> = new Map()
|
||||
private lastMessageTs?: number
|
||||
|
||||
/**
|
||||
* Create a new pending ask and add it to the queue
|
||||
* @returns The unique askId for this ask operation
|
||||
*/
|
||||
createPendingAsk(askTs: number): string {
|
||||
const askId = `ask-${askTs}-${Math.random().toString(36).substr(2, 9)}`
|
||||
this.queue.set(askId, {
|
||||
askId,
|
||||
askTs,
|
||||
resolved: false,
|
||||
})
|
||||
this.lastMessageTs = askTs
|
||||
return askId
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a pending ask with user response
|
||||
* @param askId The unique ID of the ask to resolve
|
||||
* @param response The user's response
|
||||
* @param text Optional text response
|
||||
* @param images Optional image attachments
|
||||
* @param files Optional file attachments
|
||||
* @returns true if ask was found and resolved, false otherwise
|
||||
*/
|
||||
resolvePendingAsk(askId: string, response: ClineAskResponse, text?: string, images?: string[], files?: string[]): boolean {
|
||||
const pendingAsk = this.queue.get(askId)
|
||||
if (!pendingAsk) {
|
||||
return false
|
||||
}
|
||||
|
||||
pendingAsk.response = response
|
||||
pendingAsk.text = text
|
||||
pendingAsk.images = images
|
||||
pendingAsk.files = files
|
||||
pendingAsk.resolved = true
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the resolution status of a pending ask
|
||||
* @param askId The unique ID of the ask
|
||||
* @returns The ask object if found, undefined otherwise
|
||||
*/
|
||||
getPendingAsk(askId: string): PendingAsk | undefined {
|
||||
return this.queue.get(askId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a resolved ask from the queue
|
||||
* @param askId The unique ID of the ask
|
||||
*/
|
||||
removePendingAsk(askId: string): void {
|
||||
this.queue.delete(askId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all pending asks (those not yet resolved)
|
||||
*/
|
||||
getPendingAsks(): PendingAsk[] {
|
||||
return Array.from(this.queue.values()).filter((ask) => !ask.resolved)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an ask was interrupted (another message came after it)
|
||||
* @param askId The unique ID of the ask
|
||||
* @returns true if this ask is no longer the most recent, false otherwise
|
||||
*/
|
||||
wasAskInterrupted(askId: string, currentLastMessageTs?: number): boolean {
|
||||
const pendingAsk = this.queue.get(askId)
|
||||
if (!pendingAsk) {
|
||||
return true // Ask was removed or never existed
|
||||
}
|
||||
|
||||
// If currentLastMessageTs is provided and is different from this ask's ts,
|
||||
// then this ask was interrupted by another message
|
||||
if (currentLastMessageTs !== undefined && currentLastMessageTs !== pendingAsk.askTs) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all pending asks (used on task abort)
|
||||
*/
|
||||
clear(): void {
|
||||
this.queue.clear()
|
||||
this.lastMessageTs = undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last message timestamp
|
||||
*/
|
||||
getLastMessageTs(): number | undefined {
|
||||
return this.lastMessageTs
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the last message timestamp (for tracking if asks were interrupted)
|
||||
*/
|
||||
setLastMessageTs(ts: number): void {
|
||||
this.lastMessageTs = ts
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { AssistantMessageContent } from "@core/assistant-message"
|
||||
import { ClineAskResponse } from "@shared/WebviewMessage"
|
||||
import { PendingAskQueue } from "./PendingAskQueue"
|
||||
import type { HookExecution } from "./types/HookExecution"
|
||||
|
||||
export class TaskState {
|
||||
@@ -21,11 +21,8 @@ export class TaskState {
|
||||
presentAssistantMessageLocked = false
|
||||
presentAssistantMessageHasPendingUpdates = false
|
||||
|
||||
// Ask/Response handling
|
||||
askResponse?: ClineAskResponse
|
||||
askResponseText?: string
|
||||
askResponseImages?: string[]
|
||||
askResponseFiles?: string[]
|
||||
// Ask/Response handling - now queue-based for concurrent ask support
|
||||
pendingAskQueue = new PendingAskQueue()
|
||||
lastMessageTs?: number
|
||||
|
||||
// Plan mode specific state
|
||||
@@ -40,6 +37,7 @@ export class TaskState {
|
||||
didAlreadyUseTool = false
|
||||
didEditFile: boolean = false
|
||||
lastToolName: string = "" // Track last tool used for consecutive call detection
|
||||
isExecutingInParallel = false // Track if currently executing tools in parallel
|
||||
|
||||
// Error tracking
|
||||
consecutiveMistakeCount: number = 0
|
||||
|
||||
@@ -101,11 +101,13 @@ export class ToolExecutor {
|
||||
images?: string[],
|
||||
files?: string[],
|
||||
partial?: boolean,
|
||||
call_id?: string,
|
||||
) => Promise<number | undefined>,
|
||||
private ask: (
|
||||
type: ClineAsk,
|
||||
text?: string,
|
||||
partial?: boolean,
|
||||
call_id?: string,
|
||||
) => Promise<{
|
||||
response: ClineAskResponse
|
||||
text?: string
|
||||
@@ -114,7 +116,11 @@ export class ToolExecutor {
|
||||
}>,
|
||||
private saveCheckpoint: (isAttemptCompletionMessage?: boolean, completionMessageTs?: number) => Promise<void>,
|
||||
private sayAndCreateMissingParamError: (toolName: ClineDefaultTool, paramName: string, relPath?: string) => Promise<any>,
|
||||
private removeLastPartialMessageIfExistsWithType: (type: "ask" | "say", askOrSay: ClineAsk | ClineSay) => Promise<void>,
|
||||
private removeLastPartialMessageIfExistsWithType: (
|
||||
type: "ask" | "say",
|
||||
askOrSay: ClineAsk | ClineSay,
|
||||
call_id?: string,
|
||||
) => Promise<void>,
|
||||
private executeCommandTool: (command: string, timeoutSeconds: number | undefined) => Promise<[boolean, any]>,
|
||||
private doesLatestTaskCompletionHaveNewChanges: () => Promise<boolean>,
|
||||
private updateFCListFromToolResponse: (taskProgress: string | undefined) => Promise<void>,
|
||||
@@ -547,7 +553,7 @@ export class ToolExecutor {
|
||||
|
||||
// Check if handler supports partial blocks with proper typing
|
||||
if (handler && "handlePartialBlock" in handler) {
|
||||
const uiHelpers = createUIHelpers(config)
|
||||
const uiHelpers = createUIHelpers(config, block.call_id)
|
||||
const partialHandler = handler as IPartialBlockHandler
|
||||
await partialHandler.handlePartialBlock(block, uiHelpers)
|
||||
}
|
||||
|
||||
+136
-47
@@ -60,7 +60,7 @@ import { HistoryItem } from "@shared/HistoryItem"
|
||||
import { DEFAULT_LANGUAGE_SETTINGS, getLanguageKey, LanguageDisplay } from "@shared/Languages"
|
||||
import { USER_CONTENT_TAGS } from "@shared/messages/constants"
|
||||
import { convertClineMessageToProto } from "@shared/proto-conversions/cline-message"
|
||||
import { ClineDefaultTool, READ_ONLY_TOOLS } from "@shared/tools"
|
||||
import { ClineDefaultTool, PARALLEL_SAFE_TOOLS, READ_ONLY_TOOLS } from "@shared/tools"
|
||||
import { ClineAskResponse } from "@shared/WebviewMessage"
|
||||
import { isClaude4PlusModelFamily, isGPT5ModelFamily, isLocalModel, isNextGenModelFamily } from "@utils/model-utils"
|
||||
import { arePathsEqual, getDesktopDir } from "@utils/path"
|
||||
@@ -586,6 +586,8 @@ export class Task {
|
||||
throw new Error("Cline instance aborted")
|
||||
}
|
||||
let askTs: number
|
||||
let askId: string | undefined
|
||||
|
||||
if (partial !== undefined) {
|
||||
const clineMessages = this.messageStateHandler.getClineMessages()
|
||||
const lastMessage = clineMessages.at(-1)
|
||||
@@ -600,18 +602,13 @@ export class Task {
|
||||
text,
|
||||
partial,
|
||||
})
|
||||
// todo be more efficient about saving and posting only new data or one whole message at a time so ignore partial for saves, and only post parts of partial message instead of whole array in new listener
|
||||
// await this.saveClineMessagesAndUpdateHistory()
|
||||
// await this.postStateToWebview()
|
||||
const protoMessage = convertClineMessageToProto(lastMessage)
|
||||
await sendPartialMessageEvent(protoMessage)
|
||||
throw new Error("Current ask promise was ignored 1")
|
||||
} else {
|
||||
// this is a new partial message, so add it with partial state
|
||||
// this.askResponse = undefined
|
||||
// this.askResponseText = undefined
|
||||
// this.askResponseImages = undefined
|
||||
askTs = Date.now()
|
||||
askId = this.taskState.pendingAskQueue.createPendingAsk(askTs)
|
||||
this.taskState.lastMessageTs = askTs
|
||||
await this.messageStateHandler.addToClineMessages({
|
||||
ts: askTs,
|
||||
@@ -627,11 +624,6 @@ export class Task {
|
||||
// partial=false means its a complete version of a previously partial message
|
||||
if (isUpdatingPreviousPartial) {
|
||||
// this is the complete version of a previously partial message, so replace the partial with the complete version
|
||||
this.taskState.askResponse = undefined
|
||||
this.taskState.askResponseText = undefined
|
||||
this.taskState.askResponseImages = undefined
|
||||
this.taskState.askResponseFiles = undefined
|
||||
|
||||
/*
|
||||
Bug for the history books:
|
||||
In the webview we use the ts as the chatrow key for the virtuoso list. Since we would update this ts right at the end of streaming, it would cause the view to flicker. The key prop has to be stable otherwise react has trouble reconciling items between renders, causing unmounting and remounting of components (flickering).
|
||||
@@ -639,22 +631,18 @@ export class Task {
|
||||
So in this case we must make sure that the message ts is never altered after first setting it.
|
||||
*/
|
||||
askTs = lastMessage.ts
|
||||
askId = this.taskState.pendingAskQueue.createPendingAsk(askTs)
|
||||
this.taskState.lastMessageTs = askTs
|
||||
// lastMessage.ts = askTs
|
||||
await this.messageStateHandler.updateClineMessage(lastMessageIndex, {
|
||||
text,
|
||||
partial: false,
|
||||
})
|
||||
// await this.postStateToWebview()
|
||||
const protoMessage = convertClineMessageToProto(lastMessage)
|
||||
await sendPartialMessageEvent(protoMessage)
|
||||
} else {
|
||||
// this is a new partial=false message, so add it like normal
|
||||
this.taskState.askResponse = undefined
|
||||
this.taskState.askResponseText = undefined
|
||||
this.taskState.askResponseImages = undefined
|
||||
this.taskState.askResponseFiles = undefined
|
||||
askTs = Date.now()
|
||||
askId = this.taskState.pendingAskQueue.createPendingAsk(askTs)
|
||||
this.taskState.lastMessageTs = askTs
|
||||
await this.messageStateHandler.addToClineMessages({
|
||||
ts: askTs,
|
||||
@@ -667,12 +655,8 @@ export class Task {
|
||||
}
|
||||
} else {
|
||||
// this is a new non-partial message, so add it like normal
|
||||
// const lastMessage = this.clineMessages.at(-1)
|
||||
this.taskState.askResponse = undefined
|
||||
this.taskState.askResponseText = undefined
|
||||
this.taskState.askResponseImages = undefined
|
||||
this.taskState.askResponseFiles = undefined
|
||||
askTs = Date.now()
|
||||
askId = this.taskState.pendingAskQueue.createPendingAsk(askTs)
|
||||
this.taskState.lastMessageTs = askTs
|
||||
await this.messageStateHandler.addToClineMessages({
|
||||
ts: askTs,
|
||||
@@ -683,30 +667,52 @@ export class Task {
|
||||
await this.postStateToWebview()
|
||||
}
|
||||
|
||||
await pWaitFor(() => this.taskState.askResponse !== undefined || this.taskState.lastMessageTs !== askTs, {
|
||||
interval: 100,
|
||||
})
|
||||
if (this.taskState.lastMessageTs !== askTs) {
|
||||
// Wait for this specific ask to be resolved
|
||||
if (!askId) {
|
||||
throw new Error("Failed to create pending ask")
|
||||
}
|
||||
|
||||
await pWaitFor(
|
||||
() => {
|
||||
const pendingAsk = this.taskState.pendingAskQueue.getPendingAsk(askId!)
|
||||
return (
|
||||
pendingAsk?.resolved === true ||
|
||||
this.taskState.pendingAskQueue.wasAskInterrupted(askId!, this.taskState.lastMessageTs)
|
||||
)
|
||||
},
|
||||
{
|
||||
interval: 100,
|
||||
},
|
||||
)
|
||||
|
||||
const pendingAsk = this.taskState.pendingAskQueue.getPendingAsk(askId)
|
||||
if (!pendingAsk || this.taskState.pendingAskQueue.wasAskInterrupted(askId, this.taskState.lastMessageTs)) {
|
||||
throw new Error("Current ask promise was ignored") // could happen if we send multiple asks in a row i.e. with command_output. It's important that when we know an ask could fail, it is handled gracefully
|
||||
}
|
||||
|
||||
const result = {
|
||||
response: this.taskState.askResponse!,
|
||||
text: this.taskState.askResponseText,
|
||||
images: this.taskState.askResponseImages,
|
||||
files: this.taskState.askResponseFiles,
|
||||
response: pendingAsk.response!,
|
||||
text: pendingAsk.text,
|
||||
images: pendingAsk.images,
|
||||
files: pendingAsk.files,
|
||||
}
|
||||
this.taskState.askResponse = undefined
|
||||
this.taskState.askResponseText = undefined
|
||||
this.taskState.askResponseImages = undefined
|
||||
this.taskState.askResponseFiles = undefined
|
||||
|
||||
this.taskState.pendingAskQueue.removePendingAsk(askId)
|
||||
return result
|
||||
}
|
||||
|
||||
async handleWebviewAskResponse(askResponse: ClineAskResponse, text?: string, images?: string[], files?: string[]) {
|
||||
this.taskState.askResponse = askResponse
|
||||
this.taskState.askResponseText = text
|
||||
this.taskState.askResponseImages = images
|
||||
this.taskState.askResponseFiles = files
|
||||
// Get all pending asks and resolve them in the order they were created
|
||||
const pendingAsks = this.taskState.pendingAskQueue.getPendingAsks()
|
||||
|
||||
if (pendingAsks.length === 0) {
|
||||
console.warn("handleWebviewAskResponse: No pending asks to resolve")
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve the first unresolved ask (FIFO order)
|
||||
const firstPendingAsk = pendingAsks[0]
|
||||
this.taskState.pendingAskQueue.resolvePendingAsk(firstPendingAsk.askId, askResponse, text, images, files)
|
||||
}
|
||||
|
||||
async say(
|
||||
@@ -715,6 +721,7 @@ export class Task {
|
||||
images?: string[],
|
||||
files?: string[],
|
||||
partial?: boolean,
|
||||
call_id?: string,
|
||||
): Promise<number | undefined> {
|
||||
// Allow hook messages even when aborted to enable proper cleanup
|
||||
if (this.taskState.abort && type !== "hook_status" && type !== "hook_output_stream") {
|
||||
@@ -729,9 +736,14 @@ export class Task {
|
||||
}
|
||||
|
||||
if (partial !== undefined) {
|
||||
const lastMessage = this.messageStateHandler.getClineMessages().at(-1)
|
||||
const clineMessages = this.messageStateHandler.getClineMessages()
|
||||
const lastMessage = clineMessages.at(-1)
|
||||
const isUpdatingPreviousPartial =
|
||||
lastMessage && lastMessage.partial && lastMessage.type === "say" && lastMessage.say === type
|
||||
lastMessage &&
|
||||
lastMessage.partial &&
|
||||
lastMessage.type === "say" &&
|
||||
lastMessage.say === type &&
|
||||
lastMessage.call_id === call_id
|
||||
if (partial) {
|
||||
if (isUpdatingPreviousPartial) {
|
||||
// existing partial message, so update it
|
||||
@@ -755,6 +767,7 @@ export class Task {
|
||||
files,
|
||||
partial,
|
||||
modelInfo,
|
||||
call_id,
|
||||
})
|
||||
await this.postStateToWebview()
|
||||
return sayTs
|
||||
@@ -788,6 +801,7 @@ export class Task {
|
||||
images,
|
||||
files,
|
||||
modelInfo,
|
||||
call_id,
|
||||
})
|
||||
await this.postStateToWebview()
|
||||
return sayTs
|
||||
@@ -805,6 +819,7 @@ export class Task {
|
||||
images,
|
||||
files,
|
||||
modelInfo,
|
||||
call_id,
|
||||
})
|
||||
await this.postStateToWebview()
|
||||
return sayTs
|
||||
@@ -821,12 +836,31 @@ export class Task {
|
||||
return formatResponse.toolError(formatResponse.missingToolParameterError(paramName))
|
||||
}
|
||||
|
||||
async removeLastPartialMessageIfExistsWithType(type: "ask" | "say", askOrSay: ClineAsk | ClineSay) {
|
||||
async removeLastPartialMessageIfExistsWithType(type: "ask" | "say", askOrSay: ClineAsk | ClineSay, call_id?: string) {
|
||||
const clineMessages = this.messageStateHandler.getClineMessages()
|
||||
const lastMessage = clineMessages.at(-1)
|
||||
if (lastMessage?.partial && lastMessage.type === type && (lastMessage.ask === askOrSay || lastMessage.say === askOrSay)) {
|
||||
this.messageStateHandler.setClineMessages(clineMessages.slice(0, -1))
|
||||
await this.messageStateHandler.saveClineMessagesAndUpdateHistory()
|
||||
|
||||
// If call_id is provided, find the message with matching call_id
|
||||
if (call_id) {
|
||||
const messageIndex = clineMessages.findIndex(
|
||||
(msg) =>
|
||||
msg.partial && msg.type === type && (msg.ask === askOrSay || msg.say === askOrSay) && msg.call_id === call_id,
|
||||
)
|
||||
if (messageIndex >= 0) {
|
||||
clineMessages.splice(messageIndex, 1)
|
||||
this.messageStateHandler.setClineMessages(clineMessages)
|
||||
await this.messageStateHandler.saveClineMessagesAndUpdateHistory()
|
||||
}
|
||||
} else {
|
||||
// Old behavior: remove the last message of this type (for sequential execution)
|
||||
const lastMessage = clineMessages.at(-1)
|
||||
if (
|
||||
lastMessage?.partial &&
|
||||
lastMessage.type === type &&
|
||||
(lastMessage.ask === askOrSay || lastMessage.say === askOrSay)
|
||||
) {
|
||||
this.messageStateHandler.setClineMessages(clineMessages.slice(0, -1))
|
||||
await this.messageStateHandler.saveClineMessagesAndUpdateHistory()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2009,6 +2043,34 @@ export class Task {
|
||||
yield* iterator
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects consecutive complete tool blocks that are safe to run in parallel.
|
||||
* Stops at the first non-parallel-safe tool or streaming tool.
|
||||
*/
|
||||
private collectConsecutiveParallelToolBlocks(): ToolUse[] {
|
||||
const toolBlocks: ToolUse[] = []
|
||||
let index = this.taskState.currentStreamingContentIndex
|
||||
|
||||
while (index < this.taskState.assistantMessageContent.length) {
|
||||
const block = this.taskState.assistantMessageContent[index]
|
||||
|
||||
// Only collect complete tool blocks
|
||||
if (block.type !== "tool_use" || block.partial) {
|
||||
break
|
||||
}
|
||||
|
||||
// Check if this tool can run in parallel
|
||||
if (!PARALLEL_SAFE_TOOLS.includes(block.name as any)) {
|
||||
break
|
||||
}
|
||||
|
||||
toolBlocks.push(block)
|
||||
index++
|
||||
}
|
||||
|
||||
return toolBlocks
|
||||
}
|
||||
|
||||
async presentAssistantMessage() {
|
||||
if (this.taskState.abort) {
|
||||
throw new Error("Cline instance aborted")
|
||||
@@ -2101,7 +2163,7 @@ export class Task {
|
||||
await this.say("text", content, undefined, undefined, block.partial)
|
||||
break
|
||||
}
|
||||
case "tool_use":
|
||||
case "tool_use": {
|
||||
// If we have a pending initial commit, we must block unsafe tools until it finishes.
|
||||
// Safe tools (read-only) can run in parallel.
|
||||
if (this.initialCheckpointCommitPromise) {
|
||||
@@ -2110,8 +2172,35 @@ export class Task {
|
||||
this.initialCheckpointCommitPromise = undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Try to execute multiple parallel-safe tools concurrently
|
||||
if (this.isParallelToolCallingEnabled()) {
|
||||
const parallelTools = this.collectConsecutiveParallelToolBlocks()
|
||||
if (parallelTools.length > 1) {
|
||||
// Set flag to indicate we're in parallel execution
|
||||
// This allows CommandExecutor to use ConcurrentCommandOrchestrator
|
||||
this.taskState.isExecutingInParallel = true
|
||||
this.commandExecutor.setParallelExecution(true)
|
||||
|
||||
try {
|
||||
// Execute all parallel tools concurrently
|
||||
await Promise.all(parallelTools.map((toolBlock) => this.toolExecutor.executeTool(toolBlock)))
|
||||
} finally {
|
||||
// Clear the parallel execution flag
|
||||
this.taskState.isExecutingInParallel = false
|
||||
this.commandExecutor.setParallelExecution(false)
|
||||
}
|
||||
|
||||
// Advance past all executed tools
|
||||
this.taskState.currentStreamingContentIndex += parallelTools.length - 1
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to sequential execution for non-parallel tools
|
||||
await this.toolExecutor.executeTool(block)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -84,7 +84,14 @@ export interface TaskServices {
|
||||
* All callback functions available to tool handlers
|
||||
*/
|
||||
export interface TaskCallbacks {
|
||||
say: (type: ClineSay, text?: string, images?: string[], files?: string[], partial?: boolean) => Promise<number | undefined>
|
||||
say: (
|
||||
type: ClineSay,
|
||||
text?: string,
|
||||
images?: string[],
|
||||
files?: string[],
|
||||
partial?: boolean,
|
||||
call_id?: string,
|
||||
) => Promise<number | undefined>
|
||||
|
||||
ask: (
|
||||
type: ClineAsk,
|
||||
@@ -101,7 +108,11 @@ export interface TaskCallbacks {
|
||||
|
||||
sayAndCreateMissingParamError: (toolName: ClineDefaultTool, paramName: string, relPath?: string) => Promise<any>
|
||||
|
||||
removeLastPartialMessageIfExistsWithType: (type: "ask" | "say", askOrSay: ClineAsk | ClineSay) => Promise<void>
|
||||
removeLastPartialMessageIfExistsWithType: (
|
||||
type: "ask" | "say",
|
||||
askOrSay: ClineAsk | ClineSay,
|
||||
call_id?: string,
|
||||
) => Promise<void>
|
||||
|
||||
executeCommandTool: (command: string, timeoutSeconds: number | undefined) => Promise<[boolean, any]>
|
||||
|
||||
|
||||
@@ -12,7 +12,14 @@ import type { TaskConfig } from "./TaskConfig"
|
||||
*/
|
||||
export interface StronglyTypedUIHelpers {
|
||||
// Core UI methods
|
||||
say: (type: ClineSay, text?: string, images?: string[], files?: string[], partial?: boolean) => Promise<number | undefined>
|
||||
say: (
|
||||
type: ClineSay,
|
||||
text?: string,
|
||||
images?: string[],
|
||||
files?: string[],
|
||||
partial?: boolean,
|
||||
call_id?: string,
|
||||
) => Promise<number | undefined>
|
||||
|
||||
ask: (
|
||||
type: ClineAsk,
|
||||
@@ -27,7 +34,11 @@ export interface StronglyTypedUIHelpers {
|
||||
|
||||
// Utility methods
|
||||
removeClosingTag: (block: ToolUse, tag: ToolParamName, text?: string) => string
|
||||
removeLastPartialMessageIfExistsWithType: (type: "ask" | "say", askOrSay: ClineAsk | ClineSay) => Promise<void>
|
||||
removeLastPartialMessageIfExistsWithType: (
|
||||
type: "ask" | "say",
|
||||
askOrSay: ClineAsk | ClineSay,
|
||||
call_id?: string,
|
||||
) => Promise<void>
|
||||
|
||||
// Approval methods
|
||||
shouldAutoApproveTool: (toolName: ClineDefaultTool) => boolean | [boolean, boolean]
|
||||
@@ -45,12 +56,14 @@ export interface StronglyTypedUIHelpers {
|
||||
/**
|
||||
* Creates strongly-typed UI helpers from a TaskConfig
|
||||
*/
|
||||
export function createUIHelpers(config: TaskConfig): StronglyTypedUIHelpers {
|
||||
export function createUIHelpers(config: TaskConfig, callId?: string): StronglyTypedUIHelpers {
|
||||
return {
|
||||
say: config.callbacks.say,
|
||||
say: (type: ClineSay, text?: string, images?: string[], files?: string[], partial?: boolean, call_id?: string) =>
|
||||
config.callbacks.say(type, text, images, files, partial, call_id ?? callId),
|
||||
ask: config.callbacks.ask,
|
||||
removeClosingTag: (block: ToolUse, tag: ToolParamName, text?: string) => removeClosingTag(block, tag, text),
|
||||
removeLastPartialMessageIfExistsWithType: config.callbacks.removeLastPartialMessageIfExistsWithType,
|
||||
removeLastPartialMessageIfExistsWithType: (type: "ask" | "say", askOrSay: ClineAsk | ClineSay, call_id?: string) =>
|
||||
config.callbacks.removeLastPartialMessageIfExistsWithType(type, askOrSay, call_id ?? callId),
|
||||
shouldAutoApproveTool: (toolName: ClineDefaultTool) => config.autoApprover.shouldAutoApproveTool(toolName),
|
||||
shouldAutoApproveToolWithPath: config.callbacks.shouldAutoApproveToolWithPath,
|
||||
askApproval: async (messageType: ClineAsk, message: string): Promise<boolean> => {
|
||||
|
||||
@@ -21,6 +21,7 @@ import { telemetryService } from "@services/telemetry"
|
||||
import { findLastIndex } from "@shared/array"
|
||||
import { ClineToolResponseContent } from "@shared/messages"
|
||||
import { orchestrateCommandExecution } from "./CommandOrchestrator"
|
||||
import { orchestrateConcurrentCommandExecution } from "./ConcurrentCommandOrchestrator"
|
||||
import { StandaloneTerminalManager } from "./standalone/StandaloneTerminalManager"
|
||||
import type {
|
||||
CommandExecutorCallbacks,
|
||||
@@ -51,6 +52,9 @@ export class CommandExecutor {
|
||||
// Flag to track if the current command was cancelled externally
|
||||
private wasCancelledExternally = false
|
||||
|
||||
// Track if we're currently in parallel execution mode
|
||||
private isParallelExecution = false
|
||||
|
||||
// Track shell integration warnings to determine when to show background terminal suggestion
|
||||
private shellIntegrationWarningTracker: ShellIntegrationWarningTracker = {
|
||||
timestamps: [],
|
||||
@@ -89,6 +93,14 @@ export class CommandExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set whether we're in parallel execution mode.
|
||||
* In parallel mode, commands don't use ask() to wait for user input on each output chunk.
|
||||
*/
|
||||
setParallelExecution(isParallel: boolean): void {
|
||||
this.isParallelExecution = isParallel
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command in the terminal.
|
||||
*
|
||||
@@ -97,6 +109,9 @@ export class CommandExecutor {
|
||||
* This ensures subagents run in hidden terminals, not cluttering the user's VSCode terminal
|
||||
* 2. Regular commands → Use the configured terminal manager based on terminalExecutionMode
|
||||
*
|
||||
* In parallel execution mode, uses ConcurrentCommandOrchestrator which streams output
|
||||
* via say() instead of ask(), avoiding conflicts with multiple concurrent commands.
|
||||
*
|
||||
* @param command The command to execute
|
||||
* @param timeoutSeconds Optional timeout in seconds
|
||||
* @returns [userRejected, result] tuple
|
||||
@@ -136,20 +151,26 @@ export class CommandExecutor {
|
||||
process.once("completed", clearCurrentProcess)
|
||||
process.once("error", clearCurrentProcess)
|
||||
|
||||
// Choose orchestrator based on execution mode
|
||||
// In parallel mode, use ConcurrentCommandOrchestrator to avoid ask() conflicts
|
||||
const orchestrator = this.isParallelExecution ? orchestrateConcurrentCommandExecution : orchestrateCommandExecution
|
||||
|
||||
// Use shared orchestration logic
|
||||
// The StandaloneTerminalManager handles background command tracking internally
|
||||
const result = await orchestrateCommandExecution(process, manager, this.callbacks, {
|
||||
const result = await orchestrator(process, manager, this.callbacks, {
|
||||
command,
|
||||
timeoutSeconds,
|
||||
// When "Proceed While Running" is triggered, track the command in the manager
|
||||
// Returns the log file path so the orchestrator can send it to the UI
|
||||
// existingOutput contains all output lines captured so far
|
||||
onProceedWhileRunning: useStandalone
|
||||
? (existingOutput: string[]) => {
|
||||
const backgroundCmd = this.standaloneManager.trackBackgroundCommand(process, command, existingOutput)
|
||||
return { logFilePath: backgroundCmd.logFilePath }
|
||||
}
|
||||
: undefined,
|
||||
// (Not used in concurrent mode, but kept for compatibility)
|
||||
onProceedWhileRunning:
|
||||
useStandalone && !this.isParallelExecution
|
||||
? (existingOutput: string[]) => {
|
||||
const backgroundCmd = this.standaloneManager.trackBackgroundCommand(process, command, existingOutput)
|
||||
return { logFilePath: backgroundCmd.logFilePath }
|
||||
}
|
||||
: undefined,
|
||||
showShellIntegrationSuggestion: this.shouldShowBackgroundTerminalSuggestion(),
|
||||
terminalType: useStandalone ? "standalone" : "vscode",
|
||||
})
|
||||
|
||||
@@ -198,8 +198,16 @@ export async function orchestrateCommandExecution(
|
||||
await flushBuffer()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Logger.error("Error while asking for command output")
|
||||
} catch (error) {
|
||||
// Handle ask being interrupted by concurrent commands
|
||||
// When multiple commands run in parallel, some asks may be ignored
|
||||
// This is expected behavior - just proceed with execution
|
||||
if (error instanceof Error && error.message.includes("ask promise was ignored")) {
|
||||
// Silently proceed - this command's ask was superseded by another concurrent command
|
||||
didContinue = true
|
||||
} else {
|
||||
Logger.error("Error while asking for command output", error)
|
||||
}
|
||||
} finally {
|
||||
// Clear the stuck timer
|
||||
if (bufferStuckTimer) {
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* ConcurrentCommandOrchestrator - Orchestration for parallel command execution.
|
||||
*
|
||||
* Unlike CommandOrchestrator which is designed for single commands,
|
||||
* this orchestrator handles multiple commands running in parallel without
|
||||
* calling ask() for each one (which would cause conflicts when parallel commands
|
||||
* try to ask at the same time).
|
||||
*
|
||||
* Key differences from CommandOrchestrator:
|
||||
* - Does NOT call ask() - output is delivered via say() directly
|
||||
* - Buffers output and streams it after command completion
|
||||
* - No "Proceed While Running" button (not needed for parallel execution)
|
||||
* - Handles concurrent output from multiple commands safely
|
||||
*/
|
||||
|
||||
import { Logger } from "@services/logging/Logger"
|
||||
import * as fs from "fs"
|
||||
import * as os from "os"
|
||||
import * as path from "path"
|
||||
import {
|
||||
CHUNK_BYTE_SIZE,
|
||||
CHUNK_DEBOUNCE_MS,
|
||||
CHUNK_LINE_COUNT,
|
||||
COMPLETION_TIMEOUT_MS,
|
||||
MAX_BYTES_BEFORE_FILE,
|
||||
MAX_LINES_BEFORE_FILE,
|
||||
} from "./constants"
|
||||
import type {
|
||||
CommandExecutorCallbacks,
|
||||
ITerminalManager,
|
||||
OrchestrationOptions,
|
||||
OrchestrationResult,
|
||||
TerminalProcessResultPromise,
|
||||
} from "./types"
|
||||
|
||||
/**
|
||||
* Orchestrate concurrent command execution without interactive asks.
|
||||
* Multiple commands can run in parallel without trying to ask for each one's output.
|
||||
*/
|
||||
export async function orchestrateConcurrentCommandExecution(
|
||||
process: TerminalProcessResultPromise,
|
||||
terminalManager: ITerminalManager,
|
||||
callbacks: CommandExecutorCallbacks,
|
||||
options: OrchestrationOptions,
|
||||
): Promise<OrchestrationResult> {
|
||||
const { timeoutSeconds, onOutputLine, terminalType = "vscode" } = options
|
||||
|
||||
// Track command execution state
|
||||
callbacks.updateBackgroundCommandState(true)
|
||||
|
||||
const clearCommandState = async () => {
|
||||
callbacks.updateBackgroundCommandState(false)
|
||||
|
||||
// Mark the command message as completed
|
||||
const clineMessages = callbacks.getClineMessages()
|
||||
const findLastIndex = (arr: any[], predicate: (item: any) => boolean) => {
|
||||
for (let i = arr.length - 1; i >= 0; i--) {
|
||||
if (predicate(arr[i])) return i
|
||||
}
|
||||
return -1
|
||||
}
|
||||
const lastCommandIndex = findLastIndex(clineMessages, (m) => m.ask === "command" || m.say === "command")
|
||||
if (lastCommandIndex !== -1) {
|
||||
await callbacks.updateClineMessage(lastCommandIndex, {
|
||||
commandCompleted: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
process.once("completed", clearCommandState)
|
||||
process.once("error", clearCommandState)
|
||||
process.catch(() => {
|
||||
clearCommandState()
|
||||
})
|
||||
|
||||
// Accumulated output lines
|
||||
const outputLines: string[] = []
|
||||
let outputBuffer: string[] = []
|
||||
let outputBufferSize: number = 0
|
||||
let chunkTimer: NodeJS.Timeout | null = null
|
||||
let completionTimer: NodeJS.Timeout | null = null
|
||||
|
||||
// Large output file-based logging state
|
||||
let isWritingToFile = false
|
||||
let largeOutputLogPath: string | null = null
|
||||
let largeOutputLogStream: fs.WriteStream | null = null
|
||||
let totalOutputBytes = 0
|
||||
let totalLineCount = 0
|
||||
|
||||
const scheduleFlush = () => {
|
||||
if (chunkTimer) {
|
||||
clearTimeout(chunkTimer)
|
||||
}
|
||||
chunkTimer = setTimeout(async () => await flushBuffer(), CHUNK_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
const flushBuffer = async (force = false) => {
|
||||
if (outputBuffer.length === 0 && !force) {
|
||||
return
|
||||
}
|
||||
|
||||
const chunk = outputBuffer.join("\n")
|
||||
outputBuffer = []
|
||||
outputBufferSize = 0
|
||||
|
||||
if (chunk) {
|
||||
// In concurrent mode, we use say() directly without ask()
|
||||
// This avoids conflicts when multiple commands output simultaneously
|
||||
await callbacks.say("command_output", chunk)
|
||||
}
|
||||
}
|
||||
|
||||
const switchToFileBased = async () => {
|
||||
if (isWritingToFile) return
|
||||
|
||||
isWritingToFile = true
|
||||
|
||||
// Flush any pending buffer to UI
|
||||
if (outputBuffer.length > 0) {
|
||||
const chunk = outputBuffer.join("\n")
|
||||
outputBuffer = []
|
||||
outputBufferSize = 0
|
||||
await callbacks.say("command_output", chunk)
|
||||
}
|
||||
|
||||
// Clear any pending flush timer
|
||||
if (chunkTimer) {
|
||||
clearTimeout(chunkTimer)
|
||||
chunkTimer = null
|
||||
}
|
||||
|
||||
// Set up file logging
|
||||
largeOutputLogPath = path.join(os.tmpdir(), `cline-large-output-${Date.now()}.log`)
|
||||
largeOutputLogStream = fs.createWriteStream(largeOutputLogPath, { flags: "a" })
|
||||
|
||||
// Write all existing lines to file
|
||||
if (outputLines.length > 0) {
|
||||
largeOutputLogStream.write(outputLines.join("\n") + "\n")
|
||||
}
|
||||
|
||||
// Notify user
|
||||
await callbacks.say(
|
||||
"command_output",
|
||||
`\n📋 Output is large (${outputLines.length} lines, ${Math.round(totalOutputBytes / 1024)}KB). Writing to: ${largeOutputLogPath}`,
|
||||
)
|
||||
}
|
||||
|
||||
const processLine = async (line: string) => {
|
||||
outputLines.push(line)
|
||||
totalLineCount++
|
||||
totalOutputBytes += line.length + 1
|
||||
|
||||
// Check if we need to switch to file-based logging
|
||||
if (totalLineCount > MAX_LINES_BEFORE_FILE || totalOutputBytes > MAX_BYTES_BEFORE_FILE) {
|
||||
await switchToFileBased()
|
||||
}
|
||||
|
||||
// If file-based logging is enabled, write to file
|
||||
if (isWritingToFile && largeOutputLogStream) {
|
||||
largeOutputLogStream.write(line + "\n")
|
||||
} else {
|
||||
// Otherwise buffer for UI delivery
|
||||
outputBuffer.push(line)
|
||||
outputBufferSize += line.length + 1
|
||||
|
||||
// Flush when buffer is full
|
||||
if (outputBuffer.length >= CHUNK_LINE_COUNT || outputBufferSize >= CHUNK_BYTE_SIZE) {
|
||||
if (chunkTimer) {
|
||||
clearTimeout(chunkTimer)
|
||||
chunkTimer = null
|
||||
}
|
||||
await flushBuffer()
|
||||
} else {
|
||||
scheduleFlush()
|
||||
}
|
||||
}
|
||||
|
||||
// Call the line output handler if provided
|
||||
if (onOutputLine) {
|
||||
onOutputLine(line)
|
||||
}
|
||||
}
|
||||
|
||||
const completionHandler = async () => {
|
||||
// Clear timers
|
||||
if (chunkTimer) {
|
||||
clearTimeout(chunkTimer)
|
||||
chunkTimer = null
|
||||
}
|
||||
if (completionTimer) {
|
||||
clearTimeout(completionTimer)
|
||||
completionTimer = null
|
||||
}
|
||||
|
||||
// Final flush
|
||||
await flushBuffer(true)
|
||||
|
||||
// Close file stream if open
|
||||
if (largeOutputLogStream) {
|
||||
largeOutputLogStream.end()
|
||||
largeOutputLogStream = null
|
||||
}
|
||||
}
|
||||
|
||||
// Set up completion timeout
|
||||
completionTimer = setTimeout(async () => {
|
||||
await completionHandler()
|
||||
}, COMPLETION_TIMEOUT_MS)
|
||||
|
||||
try {
|
||||
// Listen for output lines
|
||||
process.on("line", async (line: string) => {
|
||||
await processLine(line)
|
||||
})
|
||||
|
||||
// Wait for process to complete
|
||||
const result = await process
|
||||
|
||||
await completionHandler()
|
||||
|
||||
// Process final output
|
||||
const terminalOutput = terminalManager.processOutput(outputLines)
|
||||
|
||||
return {
|
||||
userRejected: false,
|
||||
result: terminalOutput,
|
||||
completed: true,
|
||||
outputLines,
|
||||
}
|
||||
} catch (error) {
|
||||
await completionHandler()
|
||||
|
||||
if (error instanceof Error) {
|
||||
Logger.error(`Concurrent command execution error: ${error.message}`)
|
||||
if (largeOutputLogPath) {
|
||||
return {
|
||||
userRejected: false,
|
||||
result: `Error: ${error.message}\n\nOutput was logged to: ${largeOutputLogPath}`,
|
||||
completed: true,
|
||||
outputLines,
|
||||
}
|
||||
}
|
||||
return {
|
||||
userRejected: false,
|
||||
result: `Error: ${error.message}`,
|
||||
completed: true,
|
||||
outputLines,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
userRejected: false,
|
||||
result: "Unknown error occurred",
|
||||
completed: true,
|
||||
outputLines,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,6 +132,7 @@ export interface ClineMessage {
|
||||
conversationHistoryIndex?: number
|
||||
conversationHistoryDeletedRange?: [number, number] // for when conversation history is truncated for API requests
|
||||
modelInfo?: ClineMessageModelInfo
|
||||
call_id?: string // Unique identifier for parallel tool execution tracking
|
||||
}
|
||||
|
||||
export type ClineAsk =
|
||||
|
||||
@@ -51,3 +51,38 @@ export const READ_ONLY_TOOLS = [
|
||||
ClineDefaultTool.WEB_FETCH,
|
||||
ClineDefaultTool.USE_SKILL,
|
||||
] as const
|
||||
|
||||
// Tools that should NEVER run in parallel with other tools
|
||||
// These tools have side effects or state dependencies that prevent concurrent execution
|
||||
export const NON_PARALLEL_TOOLS = [
|
||||
ClineDefaultTool.FILE_EDIT,
|
||||
ClineDefaultTool.APPLY_PATCH,
|
||||
ClineDefaultTool.NEW_RULE,
|
||||
ClineDefaultTool.MCP_USE,
|
||||
ClineDefaultTool.CONDENSE,
|
||||
ClineDefaultTool.SUMMARIZE_TASK,
|
||||
ClineDefaultTool.ATTEMPT,
|
||||
ClineDefaultTool.PLAN_MODE,
|
||||
ClineDefaultTool.ACT_MODE,
|
||||
ClineDefaultTool.NEW_TASK,
|
||||
ClineDefaultTool.GENERATE_EXPLANATION,
|
||||
] as const
|
||||
|
||||
// Tools that CAN run in parallel with other tools (but see NON_PARALLEL_TOOLS for exclusions)
|
||||
// These are primarily read-only tools that don't affect state
|
||||
export const PARALLEL_SAFE_TOOLS = [
|
||||
ClineDefaultTool.BASH,
|
||||
ClineDefaultTool.LIST_FILES,
|
||||
ClineDefaultTool.FILE_READ,
|
||||
ClineDefaultTool.SEARCH,
|
||||
ClineDefaultTool.LIST_CODE_DEF,
|
||||
ClineDefaultTool.WEB_SEARCH,
|
||||
ClineDefaultTool.WEB_FETCH,
|
||||
ClineDefaultTool.BROWSER,
|
||||
ClineDefaultTool.ASK,
|
||||
ClineDefaultTool.FILE_NEW,
|
||||
ClineDefaultTool.MCP_ACCESS,
|
||||
ClineDefaultTool.MCP_DOCS,
|
||||
ClineDefaultTool.REPORT_BUG,
|
||||
ClineDefaultTool.USE_SKILL,
|
||||
] as const
|
||||
|
||||
Reference in New Issue
Block a user