Compare commits

...
Author SHA1 Message Date
abeatrix ba464b2845 refactor: add parallel command execution architecture documentation
Add comprehensive documentation for the parallel command execution system that enables multiple `execute_command` tools to run concurrently without conflicts.

**Key Changes:**
- Document queue-based ask/response system using `PendingAskQueue` to handle concurrent ask operations with unique IDs
- Explain `ConcurrentCommandOrchestrator` that streams output via `say()` instead of blocking on `ask()` calls
- Detail orchestration flow with `isExecutingInParallel` flag and parallel-safe command execution
- List key implementation files and their responsibilities

**Why:**
The previous single-state approach (`askResponse`/`askResponseText`) caused conflicts when multiple commands tried to use `ask()` simultaneously. This new architecture uses a queue with unique ask IDs to safely handle concurrent interactive operations during parallel tool execution.

**Implementation:**
- `PendingAskQueue` manages concurrent asks with FIFO response resolution
- `ConcurrentCommandOrchestrator` provides non-blocking output streaming
- Flags (`isExecutingInParallel`, `setParallelExecution`) control orchestrator selection
- Proper cleanup in finally blocks ensures state consistency
<response_metadata>
{
  "conventional_commit_type": "docs",
  "scope": "parallel-execution",
  "breaking_change": false
}
</response_metadata>
2026-01-26 12:53:54 -08:00
12 changed files with 669 additions and 71 deletions
+29
View File
@@ -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
+129
View File
@@ -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
}
}
+4 -6
View File
@@ -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
+8 -2
View File
@@ -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
View File
@@ -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
}
}
/*
+13 -2
View File
@@ -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]>
+18 -5
View File
@@ -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> => {
+28 -7
View File
@@ -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,
}
}
}
+1
View File
@@ -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 =
+35
View File
@@ -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