mirror of
https://github.com/cline/cline.git
synced 2026-09-01 15:11:04 +08:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 96f9596de3 | |||
| 105bc94714 | |||
| c4edc400b4 | |||
| 353e85b1be | |||
| 45c742133c | |||
| f6580fde22 | |||
| 783add20ee | |||
| 6155e692a4 | |||
| b14a4d7e12 | |||
| a484524f54 | |||
| 8f95013590 | |||
| 06c798bc4f |
@@ -20,7 +20,6 @@ import { UserInfo } from "@shared/UserInfo"
|
||||
import { fileExistsAtPath } from "@utils/fs"
|
||||
import axios from "axios"
|
||||
import fs from "fs/promises"
|
||||
import pWaitFor from "p-wait-for"
|
||||
import * as path from "path"
|
||||
import type { FolderLockWithRetryResult } from "src/core/locks/types"
|
||||
import * as vscode from "vscode"
|
||||
@@ -435,52 +434,19 @@ export class Controller {
|
||||
try {
|
||||
this.updateBackgroundCommandState(false)
|
||||
|
||||
// Task.abortTask() now handles everything:
|
||||
// - Runs TaskCancel hook if needed
|
||||
// - Presents resume button and waits for user
|
||||
// - Runs TaskResume hook
|
||||
// - Starts task execution in background
|
||||
// No need for Controller to do any re-initialization or double-checking
|
||||
try {
|
||||
await this.task.abortTask()
|
||||
} catch (error) {
|
||||
console.error("Failed to abort task", error)
|
||||
}
|
||||
|
||||
await pWaitFor(
|
||||
() =>
|
||||
this.task === undefined ||
|
||||
this.task.taskState.isStreaming === false ||
|
||||
this.task.taskState.didFinishAbortingStream ||
|
||||
this.task.taskState.isWaitingForFirstChunk, // if only first chunk is processed, then there's no need to wait for graceful abort (closes edits, browser, etc)
|
||||
{
|
||||
timeout: 3_000,
|
||||
},
|
||||
).catch(() => {
|
||||
console.error("Failed to abort task")
|
||||
})
|
||||
|
||||
if (this.task) {
|
||||
// 'abandoned' will prevent this cline instance from affecting future cline instance gui. this may happen if its hanging on a streaming request
|
||||
this.task.taskState.abandoned = true
|
||||
}
|
||||
|
||||
// Small delay to ensure state manager has persisted the history update
|
||||
//await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
// NOW try to get history after abort has finished (hook may have saved messages)
|
||||
let historyItem: HistoryItem | undefined
|
||||
try {
|
||||
const result = await this.getTaskWithId(this.task.taskId)
|
||||
historyItem = result.historyItem
|
||||
} catch (error) {
|
||||
// Task not in history yet (new task with no messages); catch the
|
||||
// error to enable the agent to continue making progress.
|
||||
console.log(`[Controller.cancelTask] Task not found in history: ${error}`)
|
||||
}
|
||||
|
||||
// Only re-initialize if we found a history item, otherwise just clear
|
||||
if (historyItem) {
|
||||
// Re-initialize task to keep it visible in UI with resume button
|
||||
await this.initTask(undefined, undefined, undefined, historyItem, undefined)
|
||||
} else {
|
||||
await this.clearTask()
|
||||
}
|
||||
|
||||
// Update UI to reflect current state
|
||||
await this.postStateToWebview()
|
||||
} finally {
|
||||
// Always clear the flag, even if cancellation fails
|
||||
@@ -488,6 +454,15 @@ export class Controller {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by Task when it enters the resume flow (_handleResumeFlow).
|
||||
* This clears the Controller's cancel guard to allow cancellation during hooks.
|
||||
*/
|
||||
clearCancelInProgress() {
|
||||
console.log(`[Controller.clearCancelInProgress] Clearing cancel guard for resume flow`)
|
||||
this.cancelInProgress = false
|
||||
}
|
||||
|
||||
updateBackgroundCommandState(running: boolean, taskId?: string) {
|
||||
const nextTaskId = running ? taskId : undefined
|
||||
if (this.backgroundCommandRunning === running && this.backgroundCommandTaskId === nextTaskId) {
|
||||
|
||||
@@ -29,7 +29,7 @@ export async function resetState(controller: Controller, request: ResetStateRequ
|
||||
}
|
||||
|
||||
if (controller.task) {
|
||||
controller.task.abortTask()
|
||||
await controller.task.abortTask("user_cancel")
|
||||
controller.task = undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -132,6 +132,11 @@ export class HookDiscoveryCache {
|
||||
const hooksDirs = await getAllHooksDirs()
|
||||
this.log(`Scanning ${hooksDirs.length} directories for ${hookName}`)
|
||||
|
||||
// Log each directory being scanned
|
||||
hooksDirs.forEach((dir, index) => {
|
||||
this.log(` [${index + 1}] ${dir}`)
|
||||
})
|
||||
|
||||
// Ensure watchers are set up for each directory (lazy initialization)
|
||||
for (const dir of hooksDirs) {
|
||||
this.ensureWatcher(dir)
|
||||
@@ -145,6 +150,11 @@ export class HookDiscoveryCache {
|
||||
|
||||
this.log(`Found ${scripts.length} scripts for ${hookName}`)
|
||||
|
||||
// Log each found script
|
||||
scripts.forEach((script, index) => {
|
||||
this.log(` [${index + 1}] ${script}`)
|
||||
})
|
||||
|
||||
// Cache the result
|
||||
this.cache.set(hookName, {
|
||||
scriptPaths: scripts,
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* HookOutputChannel - Explicit pub-sub routing for hook outputs
|
||||
*
|
||||
* This class provides a dedicated communication channel for each hook execution,
|
||||
* ensuring that outputs are routed to the correct hook message without relying
|
||||
* on post-hoc timestamp matching.
|
||||
*
|
||||
* Architecture:
|
||||
* - Each hook gets its own channel instance
|
||||
* - Channel handles output routing via timestamp prefix
|
||||
* - Timestamps preserved for message identity
|
||||
* - Channels handle routing concern separately
|
||||
*/
|
||||
|
||||
export class HookOutputChannel {
|
||||
private hookTs: number
|
||||
private say: (type: any, text?: string, images?: string[], files?: string[], partial?: boolean) => Promise<number | undefined>
|
||||
|
||||
/**
|
||||
* Creates a new output channel for a specific hook execution
|
||||
* @param hookTs The timestamp of the hook message this channel routes to
|
||||
* @param say The say function for writing messages
|
||||
*/
|
||||
constructor(
|
||||
hookTs: number,
|
||||
say: (type: any, text?: string, images?: string[], files?: string[], partial?: boolean) => Promise<number | undefined>,
|
||||
) {
|
||||
this.hookTs = hookTs
|
||||
this.say = say
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes a line of output to this hook's message stream
|
||||
* The line is automatically prefixed with the hook's timestamp for routing
|
||||
* @param line The output line to publish
|
||||
*/
|
||||
async publish(line: string): Promise<void> {
|
||||
const prefixedOutput = `${this.hookTs}:${line}`
|
||||
try {
|
||||
await this.say("hook_output", prefixedOutput)
|
||||
} catch (error) {
|
||||
console.error(`[HookOutputChannel ${this.hookTs}] Failed to publish output:`, error)
|
||||
// Don't throw - allow hook execution to continue even if output fails
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the timestamp this channel routes to
|
||||
* Useful for debugging and verification
|
||||
*/
|
||||
getHookTimestamp(): number {
|
||||
return this.hookTs
|
||||
}
|
||||
}
|
||||
+154
-29
@@ -1,7 +1,7 @@
|
||||
import { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { MessageStateHandler } from "../task/message-state"
|
||||
import { HookExecutionError } from "./HookError"
|
||||
import { HookFactory } from "./hook-factory"
|
||||
import { HookFactory, HookStreamCallback } from "./hook-factory"
|
||||
|
||||
export interface HookExecutionOptions<Name extends keyof Hooks = any> {
|
||||
hookName: Name
|
||||
@@ -13,8 +13,9 @@ export interface HookExecutionOptions<Name extends keyof Hooks = any> {
|
||||
toolName: string | undefined
|
||||
messageTs: number
|
||||
abortController: AbortController
|
||||
scriptPath?: string
|
||||
}) => Promise<void>
|
||||
clearActiveHookExecution?: () => Promise<void>
|
||||
clearActiveHookExecution?: (messageTs: number) => Promise<void>
|
||||
messageStateHandler: MessageStateHandler
|
||||
taskId: string
|
||||
hooksEnabled: boolean
|
||||
@@ -35,6 +36,9 @@ export interface HookExecutionResult {
|
||||
/**
|
||||
* Executes a hook with standardized error handling, status tracking, and cleanup.
|
||||
* This consolidates the common pattern used across all hook execution sites.
|
||||
*
|
||||
* When multiple hooks exist (e.g., global + workspace), each hook gets its own
|
||||
* background terminal with separate output streaming.
|
||||
*/
|
||||
export async function executeHook<Name extends keyof Hooks>(options: HookExecutionOptions<Name>): Promise<HookExecutionResult> {
|
||||
const {
|
||||
@@ -56,57 +60,170 @@ export async function executeHook<Name extends keyof Hooks>(options: HookExecuti
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the hook exists
|
||||
// Discover all hook scripts for this hook type
|
||||
const hookFactory = new HookFactory()
|
||||
const hasHook = await hookFactory.hasHook(hookName)
|
||||
const hookScripts = await hookFactory.discoverHookScripts(hookName)
|
||||
|
||||
if (!hasHook) {
|
||||
if (hookScripts.length === 0) {
|
||||
return {
|
||||
wasCancelled: false,
|
||||
}
|
||||
}
|
||||
|
||||
let hookMessageTs: number | undefined
|
||||
const abortController = new AbortController()
|
||||
// Create hook messages sequentially to ensure unique timestamps
|
||||
// (but execute the hooks themselves in parallel for performance)
|
||||
const hookExecutions: Array<Promise<HookExecutionResult>> = []
|
||||
|
||||
try {
|
||||
// Show hook execution indicator and capture timestamp
|
||||
for (const scriptPath of hookScripts) {
|
||||
// Create the hook message first (sequentially to get unique timestamp)
|
||||
const hookMetadata = {
|
||||
hookName,
|
||||
scriptPath,
|
||||
...(options.toolName && { toolName: options.toolName }),
|
||||
status: "running",
|
||||
...(options.pendingToolInfo && { pendingToolInfo: options.pendingToolInfo }),
|
||||
}
|
||||
hookMessageTs = await say("hook", JSON.stringify(hookMetadata))
|
||||
const hookMessageTs = await say("hook", JSON.stringify(hookMetadata))
|
||||
|
||||
// Add small delay to ensure different timestamps even on fast systems
|
||||
await new Promise((resolve) => setTimeout(resolve, 1))
|
||||
|
||||
// Log for debugging
|
||||
console.log(`[Hook ${hookName}] Created message ts=${hookMessageTs} for ${scriptPath}`)
|
||||
|
||||
// Now start the hook execution (don't await - let them run in parallel)
|
||||
const execution = executeIndividualHook({
|
||||
scriptPath,
|
||||
hookName,
|
||||
hookInput,
|
||||
isCancellable,
|
||||
say,
|
||||
setActiveHookExecution,
|
||||
clearActiveHookExecution,
|
||||
messageStateHandler,
|
||||
taskId,
|
||||
toolName: options.toolName,
|
||||
pendingToolInfo: options.pendingToolInfo,
|
||||
hookMessageTs, // Pass the pre-created timestamp
|
||||
})
|
||||
|
||||
hookExecutions.push(execution)
|
||||
}
|
||||
|
||||
// Wait for all hook executions to complete in parallel
|
||||
const results = await Promise.all(hookExecutions)
|
||||
|
||||
// Merge results:
|
||||
// - If ANY hook was cancelled by user, return wasCancelled: true
|
||||
// - If ANY hook requests task cancellation, return cancel: true
|
||||
// - Combine all context modifications
|
||||
// - Combine all error messages
|
||||
|
||||
const wasCancelled = results.some((r) => r.wasCancelled)
|
||||
const cancel = results.some((r) => r.cancel === true)
|
||||
const contextModification = results
|
||||
.map((r) => r.contextModification?.trim())
|
||||
.filter((mod) => mod)
|
||||
.join("\n\n")
|
||||
const errorMessage = results
|
||||
.map((r) => r.errorMessage?.trim())
|
||||
.filter((msg) => msg)
|
||||
.join("\n")
|
||||
|
||||
return {
|
||||
cancel: cancel || undefined,
|
||||
contextModification: contextModification || undefined,
|
||||
errorMessage: errorMessage || undefined,
|
||||
wasCancelled,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single hook script with its own terminal message and output stream.
|
||||
*/
|
||||
async function executeIndividualHook<Name extends keyof Hooks>(params: {
|
||||
scriptPath: string
|
||||
hookName: Name
|
||||
hookInput: Hooks[Name]
|
||||
isCancellable: boolean
|
||||
say: (type: any, text?: string, images?: string[], files?: string[], partial?: boolean) => Promise<number | undefined>
|
||||
setActiveHookExecution?: (execution: {
|
||||
hookName: string
|
||||
toolName: string | undefined
|
||||
messageTs: number
|
||||
abortController: AbortController
|
||||
scriptPath?: string
|
||||
}) => Promise<void>
|
||||
clearActiveHookExecution?: (messageTs: number) => Promise<void>
|
||||
messageStateHandler: MessageStateHandler
|
||||
taskId: string
|
||||
toolName?: string
|
||||
pendingToolInfo?: any
|
||||
hookMessageTs?: number // Pre-created timestamp for this hook message
|
||||
}): Promise<HookExecutionResult> {
|
||||
const {
|
||||
scriptPath,
|
||||
hookName,
|
||||
hookInput,
|
||||
isCancellable,
|
||||
say,
|
||||
setActiveHookExecution,
|
||||
clearActiveHookExecution,
|
||||
messageStateHandler,
|
||||
taskId,
|
||||
toolName,
|
||||
pendingToolInfo,
|
||||
hookMessageTs: providedHookMessageTs,
|
||||
} = params
|
||||
|
||||
let hookMessageTs: number | undefined = providedHookMessageTs
|
||||
const abortController = new AbortController()
|
||||
|
||||
try {
|
||||
// Only create hook message if not already created
|
||||
if (hookMessageTs === undefined) {
|
||||
const hookMetadata = {
|
||||
hookName,
|
||||
scriptPath, // Include script path to identify which hook this is
|
||||
...(toolName && { toolName }),
|
||||
status: "running",
|
||||
...(pendingToolInfo && { pendingToolInfo }),
|
||||
}
|
||||
hookMessageTs = await say("hook", JSON.stringify(hookMetadata))
|
||||
}
|
||||
|
||||
// Track active hook execution for cancellation (only if cancellable and message was created)
|
||||
if (isCancellable && hookMessageTs !== undefined && setActiveHookExecution) {
|
||||
await setActiveHookExecution({
|
||||
hookName,
|
||||
toolName: options.toolName,
|
||||
toolName,
|
||||
messageTs: hookMessageTs,
|
||||
abortController,
|
||||
scriptPath,
|
||||
})
|
||||
}
|
||||
|
||||
// Create streaming callback
|
||||
const streamCallback = async (line: string) => {
|
||||
await say("hook_output", line)
|
||||
// Create dedicated output channel for this hook (pub-sub architecture)
|
||||
// The channel handles routing outputs to the correct hook message
|
||||
const outputChannel = new (await import("./HookOutputChannel")).HookOutputChannel(hookMessageTs!, say)
|
||||
|
||||
// Create streaming callback that publishes to the channel
|
||||
// Channel internally handles the timestamp prefixing for routing
|
||||
const streamCallback: HookStreamCallback = async (line: string) => {
|
||||
await outputChannel.publish(line)
|
||||
}
|
||||
|
||||
// Create and execute hook
|
||||
const hook = await hookFactory.createWithStreaming(
|
||||
hookName,
|
||||
streamCallback,
|
||||
isCancellable ? abortController.signal : undefined,
|
||||
)
|
||||
// Create runner directly for THIS SPECIFIC SCRIPT ONLY
|
||||
// Don't use createWithStreaming() as it rediscovers all scripts!
|
||||
const { StdioHookRunner } = await import("./hook-factory")
|
||||
const hook = new StdioHookRunner(hookName, scriptPath, streamCallback, isCancellable ? abortController.signal : undefined)
|
||||
|
||||
const result = await hook.run({
|
||||
taskId,
|
||||
...hookInput,
|
||||
})
|
||||
|
||||
console.log(`[${hookName} Hook]`, result)
|
||||
console.log(`[${hookName} Hook - ${scriptPath}]`, result)
|
||||
|
||||
// Check if hook wants to cancel
|
||||
if (result.cancel === true) {
|
||||
@@ -114,7 +231,8 @@ export async function executeHook<Name extends keyof Hooks>(options: HookExecuti
|
||||
if (hookMessageTs !== undefined) {
|
||||
await updateHookMessage(messageStateHandler, hookMessageTs, {
|
||||
hookName,
|
||||
...(options.toolName && { toolName: options.toolName }),
|
||||
scriptPath,
|
||||
...(toolName && { toolName }),
|
||||
status: "cancelled",
|
||||
exitCode: 130,
|
||||
hasJsonResponse: true,
|
||||
@@ -130,15 +248,16 @@ export async function executeHook<Name extends keyof Hooks>(options: HookExecuti
|
||||
}
|
||||
|
||||
// Clear active hook execution after successful completion (only if cancellable)
|
||||
if (isCancellable && clearActiveHookExecution) {
|
||||
await clearActiveHookExecution()
|
||||
if (isCancellable && clearActiveHookExecution && hookMessageTs !== undefined) {
|
||||
await clearActiveHookExecution(hookMessageTs)
|
||||
}
|
||||
|
||||
// Update hook status to completed (only if not cancelled)
|
||||
if (hookMessageTs !== undefined) {
|
||||
await updateHookMessage(messageStateHandler, hookMessageTs, {
|
||||
hookName,
|
||||
...(options.toolName && { toolName: options.toolName }),
|
||||
scriptPath,
|
||||
...(toolName && { toolName }),
|
||||
status: "completed",
|
||||
exitCode: 0,
|
||||
hasJsonResponse: true,
|
||||
@@ -153,8 +272,8 @@ export async function executeHook<Name extends keyof Hooks>(options: HookExecuti
|
||||
}
|
||||
} catch (hookError) {
|
||||
// Clear active hook execution (only if cancellable)
|
||||
if (isCancellable && clearActiveHookExecution) {
|
||||
await clearActiveHookExecution()
|
||||
if (isCancellable && clearActiveHookExecution && hookMessageTs !== undefined) {
|
||||
await clearActiveHookExecution(hookMessageTs)
|
||||
}
|
||||
|
||||
// Check if this was a user cancellation via abort controller
|
||||
@@ -163,6 +282,7 @@ export async function executeHook<Name extends keyof Hooks>(options: HookExecuti
|
||||
if (hookMessageTs !== undefined) {
|
||||
await updateHookMessage(messageStateHandler, hookMessageTs, {
|
||||
hookName,
|
||||
scriptPath,
|
||||
status: "cancelled",
|
||||
exitCode: 130,
|
||||
})
|
||||
@@ -182,6 +302,7 @@ export async function executeHook<Name extends keyof Hooks>(options: HookExecuti
|
||||
if (hookMessageTs !== undefined) {
|
||||
await updateHookMessage(messageStateHandler, hookMessageTs, {
|
||||
hookName,
|
||||
scriptPath,
|
||||
status: "failed",
|
||||
exitCode: errorInfo?.exitCode ?? 1,
|
||||
...(errorInfo && {
|
||||
@@ -196,7 +317,7 @@ export async function executeHook<Name extends keyof Hooks>(options: HookExecuti
|
||||
}
|
||||
|
||||
// Log error for non-cancellable hooks or unexpected errors
|
||||
console.error(`${hookName} hook failed:`, hookError)
|
||||
console.error(`${hookName} hook failed (${scriptPath}):`, hookError)
|
||||
|
||||
// Return safe defaults for all fields to avoid undefined property access
|
||||
return {
|
||||
@@ -209,7 +330,8 @@ export async function executeHook<Name extends keyof Hooks>(options: HookExecuti
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to update hook message status in message state
|
||||
* Helper to update hook message status in message state.
|
||||
* Ensures the update is persisted and posted to webview before returning.
|
||||
*/
|
||||
async function updateHookMessage(
|
||||
messageStateHandler: MessageStateHandler,
|
||||
@@ -222,5 +344,8 @@ async function updateHookMessage(
|
||||
await messageStateHandler.updateClineMessage(hookMessageIndex, {
|
||||
text: JSON.stringify(metadata),
|
||||
})
|
||||
// CRITICAL: Persist and post the update so it's reflected in the UI
|
||||
// before the hook execution function returns
|
||||
await messageStateHandler.saveClineMessagesAndUpdateHistory()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,7 +241,7 @@ export type HookStreamCallback = (line: string, stream: "stdout" | "stderr") =>
|
||||
*
|
||||
* @template Name The type of hook this runner represents
|
||||
*/
|
||||
class StdioHookRunner<Name extends HookName> extends HookRunner<Name> {
|
||||
export class StdioHookRunner<Name extends HookName> extends HookRunner<Name> {
|
||||
constructor(
|
||||
hookName: Name,
|
||||
public readonly scriptPath: string,
|
||||
@@ -494,6 +494,16 @@ export class HookFactory {
|
||||
return scripts.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover all hook scripts for the given hook name without creating runners.
|
||||
* This is useful when you want to execute each hook individually with separate contexts.
|
||||
* @returns Array of paths to hook scripts
|
||||
*/
|
||||
async discoverHookScripts<Name extends HookName>(hookName: Name): Promise<string[]> {
|
||||
const { HookDiscoveryCache } = await import("./HookDiscoveryCache")
|
||||
return await HookDiscoveryCache.getInstance().get(hookName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a hook runner without streaming support (backwards compatible)
|
||||
*/
|
||||
|
||||
@@ -61,14 +61,52 @@ export class TaskState {
|
||||
didFinishAbortingStream = false
|
||||
abandoned = false
|
||||
|
||||
// Hook execution tracking for cancellation
|
||||
// ============================================================================
|
||||
// HOOK STATE - Dual Architecture for Feature Flag Protection
|
||||
// ============================================================================
|
||||
// These fields exist in two forms to support both legacy (hooks disabled)
|
||||
// and new (hooks enabled) architectures without breaking existing code.
|
||||
|
||||
// LEGACY STRUCTURE (used when hooks feature flag is DISABLED)
|
||||
// Single hook execution tracking for cancellation
|
||||
activeHookExecution?: {
|
||||
hookName: string
|
||||
toolName?: string
|
||||
messageTs: number
|
||||
abortController: AbortController
|
||||
scriptPath?: string
|
||||
}
|
||||
|
||||
// NEW STRUCTURE (used when hooks feature flag is ENABLED)
|
||||
// Multi-hook execution tracking via Map for concurrent hooks
|
||||
activeHookExecutions: Map<
|
||||
number,
|
||||
{
|
||||
hookName: string
|
||||
toolName?: string
|
||||
messageTs: number
|
||||
abortController: AbortController
|
||||
scriptPath?: string
|
||||
}
|
||||
> = new Map()
|
||||
|
||||
// NEW ABORT FLOW ENHANCEMENTS (only used when hooks enabled)
|
||||
// Single-flight guard to prevent concurrent abortTask() calls
|
||||
isAborting: boolean = false
|
||||
abortPromise?: Promise<{ waitingAtResumeButton: boolean; abortReason: "user_cancel" | "internal_resume" }>
|
||||
// Abort reason to distinguish user cancellation from internal resume flow
|
||||
abortReason: "user_cancel" | "internal_resume" = "user_cancel"
|
||||
|
||||
// Session work tracking for TaskCancel hook decision
|
||||
// Set to true when substantive work begins (API request, tool execution, user feedback)
|
||||
// Used by shouldRunTaskCancelHook() to determine if TaskCancel should run
|
||||
// Eliminates race conditions from checking "currently active" work indicators
|
||||
didPerformWork: boolean = false
|
||||
|
||||
// Flag to prevent duplicate TaskCancel hook execution
|
||||
// Set to true once TaskCancel hook has run, prevents running it again on subsequent abortTask() calls
|
||||
didRunTaskCancelHook: boolean = false
|
||||
|
||||
// Auto-context summarization
|
||||
currentlySummarizing: boolean = false
|
||||
lastAutoCompactTriggerIndex?: number
|
||||
|
||||
@@ -114,10 +114,15 @@ export class ToolExecutor {
|
||||
private updateFCListFromToolResponse: (taskProgress: string | undefined) => Promise<void>,
|
||||
private switchToActMode: () => Promise<boolean>,
|
||||
|
||||
// Atomic hook state helpers from Task
|
||||
private setActiveHookExecution: (hookExecution: NonNullable<typeof taskState.activeHookExecution>) => Promise<void>,
|
||||
private clearActiveHookExecution: () => Promise<void>,
|
||||
private getActiveHookExecution: () => Promise<typeof taskState.activeHookExecution>,
|
||||
// Atomic hook state helpers from Task (Map-based API)
|
||||
private setActiveHookExecution: (hookExecution: {
|
||||
hookName: string
|
||||
toolName: string | undefined
|
||||
messageTs: number
|
||||
abortController: AbortController
|
||||
scriptPath?: string
|
||||
}) => Promise<void>,
|
||||
private clearActiveHookExecution: (messageTs: number) => Promise<void>,
|
||||
) {
|
||||
this.autoApprover = new AutoApprove(this.stateManager)
|
||||
|
||||
|
||||
+542
-148
@@ -151,35 +151,149 @@ export class Task {
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically set active hook execution with mutex protection
|
||||
* Prevents TOCTOU races when setting hook execution state
|
||||
* PUBLIC: Exposed for ToolExecutor to use
|
||||
* Atomically set active hook execution with mutex protection.
|
||||
*
|
||||
* FEATURE FLAG PROTECTED: Supports both legacy single-hook and new multi-hook Map architectures.
|
||||
*
|
||||
* **Legacy Mode (hooks disabled):**
|
||||
* - Stores single hook in activeHookExecution
|
||||
* - Overwrites any previous hook
|
||||
*
|
||||
* **New Mode (hooks enabled):**
|
||||
* - Uses Map<messageTs, HookExecution> to track multiple concurrent hooks
|
||||
* - Each hook gets its own entry keyed by its message timestamp
|
||||
* - Supports concurrent execution of multiple hooks (e.g., global + workspace)
|
||||
*
|
||||
* **Cancellation Flow (new mode):**
|
||||
* 1. Hook starts → setActiveHookExecution() adds to Map
|
||||
* 2. Hook completes → clearActiveHookExecution(messageTs) removes from Map
|
||||
* 3. User cancels → abortTask() iterates Map, calls abort() on all
|
||||
*
|
||||
* @param hookExecution The hook execution state to track
|
||||
* @param hookExecution.hookName Name of the hook (e.g., "PreToolUse")
|
||||
* @param hookExecution.toolName Optional tool name for tool-specific hooks
|
||||
* @param hookExecution.messageTs Unique timestamp identifying this hook's message
|
||||
* @param hookExecution.abortController Controller for cancelling the hook
|
||||
* @param hookExecution.scriptPath Path to the hook script for identification
|
||||
*
|
||||
* @public Exposed for hook-executor.ts to coordinate hook lifecycle
|
||||
*/
|
||||
public async setActiveHookExecution(hookExecution: NonNullable<typeof this.taskState.activeHookExecution>): Promise<void> {
|
||||
public async setActiveHookExecution(hookExecution: {
|
||||
hookName: string
|
||||
toolName: string | undefined
|
||||
messageTs: number
|
||||
abortController: AbortController
|
||||
scriptPath?: string
|
||||
}): Promise<void> {
|
||||
const hooksEnabled = featureFlagsService.getHooksEnabled() && this.stateManager.getGlobalSettingsKey("hooksEnabled")
|
||||
|
||||
await this.withStateLock(() => {
|
||||
this.taskState.activeHookExecution = hookExecution
|
||||
if (hooksEnabled) {
|
||||
// New Map-based architecture for concurrent multi-hook support
|
||||
this.taskState.activeHookExecutions.set(hookExecution.messageTs, hookExecution)
|
||||
} else {
|
||||
// Legacy single-hook architecture
|
||||
this.taskState.activeHookExecution = hookExecution
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically clear active hook execution with mutex protection
|
||||
* Prevents TOCTOU races when clearing hook execution state
|
||||
* PUBLIC: Exposed for ToolExecutor to use
|
||||
* Atomically clear active hook execution with mutex protection.
|
||||
*
|
||||
* FEATURE FLAG PROTECTED: Supports both legacy single-hook and new multi-hook Map architectures.
|
||||
*
|
||||
* **Legacy Mode (hooks disabled):**
|
||||
* - Clears the single activeHookExecution (messageTs parameter ignored)
|
||||
*
|
||||
* **New Mode (hooks enabled):**
|
||||
* - Removes specific hook from the active executions Map
|
||||
* - If entry doesn't exist (e.g., already cleared by abortTask), this is a no-op
|
||||
*
|
||||
* @param messageTs The unique timestamp of the hook execution to clear (used in new mode only)
|
||||
*
|
||||
* @public Exposed for hook-executor.ts to signal hook completion
|
||||
*/
|
||||
public async clearActiveHookExecution(): Promise<void> {
|
||||
public async clearActiveHookExecution(messageTs?: number): Promise<void> {
|
||||
const hooksEnabled = featureFlagsService.getHooksEnabled() && this.stateManager.getGlobalSettingsKey("hooksEnabled")
|
||||
|
||||
await this.withStateLock(() => {
|
||||
this.taskState.activeHookExecution = undefined
|
||||
if (hooksEnabled && messageTs !== undefined) {
|
||||
// New Map-based architecture - delete specific hook by messageTs
|
||||
this.taskState.activeHookExecutions.delete(messageTs)
|
||||
} else {
|
||||
// Legacy single-hook architecture - clear the single hook
|
||||
this.taskState.activeHookExecution = undefined
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically read active hook execution state with mutex protection
|
||||
* Returns a snapshot of the current state to prevent TOCTOU races
|
||||
* PUBLIC: Exposed for ToolExecutor to use
|
||||
* Atomically clear all active hook executions with mutex protection.
|
||||
*
|
||||
* FEATURE FLAG PROTECTED: Only clears state for the currently active architecture.
|
||||
*
|
||||
* Used during task cancellation to ensure all hook state is properly cleaned up.
|
||||
* This is called by abortTask() AFTER aborting all hooks.
|
||||
*
|
||||
* @public Used internally by abortTask()
|
||||
*/
|
||||
public async getActiveHookExecution(): Promise<typeof this.taskState.activeHookExecution> {
|
||||
public async clearAllActiveHookExecutions(): Promise<void> {
|
||||
const hooksEnabled = featureFlagsService.getHooksEnabled() && this.stateManager.getGlobalSettingsKey("hooksEnabled")
|
||||
|
||||
await this.withStateLock(() => {
|
||||
if (hooksEnabled) {
|
||||
// New Map-based architecture - clear the entire Map
|
||||
this.taskState.activeHookExecutions.clear()
|
||||
} else {
|
||||
// Legacy single-hook architecture - clear the single hook
|
||||
this.taskState.activeHookExecution = undefined
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically read active hook execution state with mutex protection.
|
||||
*
|
||||
* FEATURE FLAG PROTECTED: Returns appropriate format for the currently active architecture.
|
||||
*
|
||||
* Returns a snapshot array of all currently active hook executions.
|
||||
* The snapshot prevents TOCTOU races - the returned array is stable
|
||||
* even if hooks complete or new hooks start after this call.
|
||||
*
|
||||
* **Legacy Mode (hooks disabled):**
|
||||
* - Returns array with single hook if one is active, empty array otherwise
|
||||
*
|
||||
* **New Mode (hooks enabled):**
|
||||
* - Returns array of all hooks active in the Map
|
||||
*
|
||||
* **Use Cases:**
|
||||
* - abortTask() uses this to get all hooks to cancel
|
||||
* - shouldRunTaskCancelHook() uses this to detect active work
|
||||
*
|
||||
* @returns Array of active hook execution states (empty if none active)
|
||||
*
|
||||
* @public Exposed for abortTask() and hook decision logic
|
||||
*/
|
||||
public async getActiveHookExecutions(): Promise<
|
||||
Array<{
|
||||
hookName: string
|
||||
toolName?: string
|
||||
messageTs: number
|
||||
abortController: AbortController
|
||||
scriptPath?: string
|
||||
}>
|
||||
> {
|
||||
const hooksEnabled = featureFlagsService.getHooksEnabled() && this.stateManager.getGlobalSettingsKey("hooksEnabled")
|
||||
|
||||
return await this.withStateLock(() => {
|
||||
return this.taskState.activeHookExecution
|
||||
if (hooksEnabled) {
|
||||
// New Map-based architecture - return all hooks as array
|
||||
return Array.from(this.taskState.activeHookExecutions.values())
|
||||
} else {
|
||||
// Legacy single-hook architecture - return array with single hook if exists
|
||||
return this.taskState.activeHookExecution ? [this.taskState.activeHookExecution] : []
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -536,10 +650,9 @@ export class Task {
|
||||
() => this.checkpointManager?.doesLatestTaskCompletionHaveNewChanges() ?? Promise.resolve(false),
|
||||
this.FocusChainManager?.updateFCListFromToolResponse.bind(this.FocusChainManager) || (async () => {}),
|
||||
this.switchToActModeCallback.bind(this),
|
||||
// Atomic hook state helpers for ToolExecutor
|
||||
// Atomic hook state helpers for ToolExecutor (Map-based API)
|
||||
this.setActiveHookExecution.bind(this),
|
||||
this.clearActiveHookExecution.bind(this),
|
||||
this.getActiveHookExecution.bind(this),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -881,6 +994,15 @@ export class Task {
|
||||
|
||||
await this.say("text", task, images, files)
|
||||
|
||||
// Mark that substantive work is starting as soon as task is displayed
|
||||
// This ensures TaskCancel will run if user cancels during hooks or early execution
|
||||
// FEATURE FLAG PROTECTED: Only track work for hooks-enabled flow
|
||||
const hooksEnabledForWork =
|
||||
featureFlagsService.getHooksEnabled() && this.stateManager.getGlobalSettingsKey("hooksEnabled")
|
||||
if (hooksEnabledForWork) {
|
||||
this.taskState.didPerformWork = true
|
||||
}
|
||||
|
||||
this.taskState.isInitialized = true
|
||||
|
||||
const imageBlocks: Anthropic.ImageBlockParam[] = formatResponse.imageBlocks(images)
|
||||
@@ -944,8 +1066,9 @@ export class Task {
|
||||
console.log(`[TaskStart Hook] Messages saved successfully, returning from hook`)
|
||||
}
|
||||
|
||||
// abortTask will handle cleanup
|
||||
this.abortTask()
|
||||
// CRITICAL: Must await abortTask() to ensure TaskCancel hooks complete
|
||||
// before the function returns and resume button is shown
|
||||
await this.abortTask("user_cancel")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1047,6 +1170,15 @@ export class Task {
|
||||
|
||||
const { response, text, images, files } = await this.ask(askType) // calls poststatetowebview
|
||||
|
||||
// Mark that substantive work begins as soon as user clicks resume
|
||||
// Even if no feedback is provided, clicking resume means work has started
|
||||
// FEATURE FLAG PROTECTED: Only track work for hooks-enabled flow
|
||||
const hooksEnabledForWork =
|
||||
featureFlagsService.getHooksEnabled() && this.stateManager.getGlobalSettingsKey("hooksEnabled")
|
||||
if (hooksEnabledForWork) {
|
||||
this.taskState.didPerformWork = true
|
||||
}
|
||||
|
||||
// Initialize newUserContent array for hook context
|
||||
const newUserContent: UserContent = []
|
||||
|
||||
@@ -1094,7 +1226,9 @@ export class Task {
|
||||
await this.postStateToWebview()
|
||||
}
|
||||
|
||||
// Return without continuing task - Controller.cancelTask() will handle showing resume button
|
||||
// CRITICAL: Must await abortTask() to ensure TaskCancel hooks complete
|
||||
// before the function returns and resume button is shown
|
||||
await this.abortTask("user_cancel")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1233,8 +1367,9 @@ export class Task {
|
||||
|
||||
// Handle hook cancellation request
|
||||
if (userPromptHookResult.cancel === true) {
|
||||
// The hook already updated its status to "cancelled" internally and saved state
|
||||
this.abortTask()
|
||||
// CRITICAL: Must await abortTask() to ensure TaskCancel hooks complete
|
||||
// before the function returns and resume button is shown
|
||||
await this.abortTask("user_cancel")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1280,55 +1415,238 @@ export class Task {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the complete resume flow after TaskCancel has run.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Present resume button to user
|
||||
* 2. Wait for user to click resume
|
||||
* 3. Run TaskResume hook
|
||||
* 4. Start task execution in background
|
||||
*
|
||||
* @returns true if resumed successfully, false if cancelled during resume
|
||||
*/
|
||||
private async _handleResumeFlow(): Promise<boolean> {
|
||||
// CRITICAL FIX: Clear Controller's cancel guard so user can cancel during hooks
|
||||
// This must happen BEFORE presenting the resume button
|
||||
this.controller.clearCancelInProgress()
|
||||
|
||||
// Reset abort flag so resume button can be shown
|
||||
this.taskState.abort = false
|
||||
|
||||
// CRITICAL: Reset didRunTaskCancelHook so future cancels will run TaskCancel again
|
||||
// This flag tracks "did TaskCancel run in THIS abort sequence", not "did it ever run"
|
||||
// When user resumes, we start a fresh sequence, so reset the flag
|
||||
this.taskState.didRunTaskCancelHook = false
|
||||
|
||||
// Get last message for context
|
||||
const lastClineMessage = this.messageStateHandler
|
||||
.getClineMessages()
|
||||
.slice()
|
||||
.reverse()
|
||||
.find((m) => !(m.ask === "resume_task" || m.ask === "resume_completed_task"))
|
||||
|
||||
let askType: ClineAsk
|
||||
if (lastClineMessage?.ask === "completion_result") {
|
||||
askType = "resume_completed_task"
|
||||
} else {
|
||||
askType = "resume_task"
|
||||
}
|
||||
|
||||
// Present resume button and wait for user
|
||||
const { response, text, images, files } = await this.ask(askType)
|
||||
|
||||
// Handle user feedback if provided
|
||||
if (response === "messageResponse" || text || (images && images.length > 0) || (files && files.length > 0)) {
|
||||
await this.say("user_feedback", text, images, files)
|
||||
await this.checkpointManager?.saveCheckpoint()
|
||||
}
|
||||
|
||||
// Prepare content for task execution
|
||||
const newUserContent: UserContent = []
|
||||
|
||||
// Run TaskResume hook
|
||||
const { executeHook } = await import("../hooks/hook-executor")
|
||||
const clineMessages = this.messageStateHandler.getClineMessages()
|
||||
const taskResumeResult = await executeHook({
|
||||
hookName: "TaskResume",
|
||||
hookInput: {
|
||||
taskResume: {
|
||||
taskMetadata: {
|
||||
taskId: this.taskId,
|
||||
ulid: this.ulid,
|
||||
},
|
||||
previousState: {
|
||||
lastMessageTs: lastClineMessage?.ts?.toString() || "",
|
||||
messageCount: clineMessages.length.toString(),
|
||||
conversationHistoryDeleted: (this.taskState.conversationHistoryDeletedRange !== undefined).toString(),
|
||||
},
|
||||
},
|
||||
},
|
||||
isCancellable: true,
|
||||
say: this.say.bind(this),
|
||||
setActiveHookExecution: this.setActiveHookExecution.bind(this),
|
||||
clearActiveHookExecution: this.clearActiveHookExecution.bind(this),
|
||||
messageStateHandler: this.messageStateHandler,
|
||||
taskId: this.taskId,
|
||||
hooksEnabled: true,
|
||||
})
|
||||
|
||||
// Check if user cancelled during TaskResume
|
||||
if (taskResumeResult.cancel === true) {
|
||||
return false // Indicate cancellation
|
||||
}
|
||||
|
||||
// Add TaskResume context if provided
|
||||
if (taskResumeResult.contextModification) {
|
||||
newUserContent.push({
|
||||
type: "text",
|
||||
text: `<hook_context source="TaskResume" type="general">\n${taskResumeResult.contextModification}\n</hook_context>`,
|
||||
})
|
||||
}
|
||||
|
||||
// Add user feedback content
|
||||
if (text) {
|
||||
newUserContent.push({
|
||||
type: "text",
|
||||
text: formatResponse.tooManyMistakes(text),
|
||||
})
|
||||
}
|
||||
if (images && images.length > 0) {
|
||||
newUserContent.push(...formatResponse.imageBlocks(images))
|
||||
}
|
||||
if (files && files.length > 0) {
|
||||
const fileContentString = await processFilesIntoText(files)
|
||||
if (fileContentString) {
|
||||
newUserContent.push({
|
||||
type: "text",
|
||||
text: fileContentString,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Run UserPromptSubmit hook for internal resume (after TaskResume for UI ordering)
|
||||
const userPromptHookResult = await this.runUserPromptSubmitHook(newUserContent, "resume")
|
||||
|
||||
// Defensive check: Verify task wasn't aborted during hook execution
|
||||
if (this.taskState.abort) {
|
||||
return false // Indicate cancellation
|
||||
}
|
||||
|
||||
// Handle hook cancellation request
|
||||
if (userPromptHookResult.cancel === true) {
|
||||
return false // Indicate cancellation
|
||||
}
|
||||
|
||||
// Add hook context if provided (after all other content)
|
||||
if (userPromptHookResult.contextModification) {
|
||||
newUserContent.push({
|
||||
type: "text",
|
||||
text: `<hook_context source="UserPromptSubmit">\n${userPromptHookResult.contextModification}\n</hook_context>`,
|
||||
})
|
||||
}
|
||||
|
||||
// Start task execution in background
|
||||
this.initiateTaskLoop(newUserContent.length > 0 ? newUserContent : []).catch((error) => {
|
||||
console.error("[Task] Background task loop failed:", error)
|
||||
})
|
||||
|
||||
return true // Indicate successful resume
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the TaskCancel hook should run.
|
||||
* Only runs if there's actual active work happening or if work was started in this session.
|
||||
* Does NOT run when just showing the resume button with no active work.
|
||||
* @returns true if the hook should run, false otherwise
|
||||
*
|
||||
* Decision Logic:
|
||||
* - DON'T run if already at resume button (previous cancellation or opened from history with no work)
|
||||
* - DO run if any substantive work was performed in this session
|
||||
*
|
||||
* This eliminates race conditions from checking "currently active" work indicators
|
||||
* and instead checks "was work started" which is stable and deterministic.
|
||||
*
|
||||
* @returns true if TaskCancel should run, false otherwise
|
||||
*/
|
||||
private async shouldRunTaskCancelHook(): Promise<boolean> {
|
||||
// Atomically check for active hook execution (work happening now)
|
||||
const activeHook = await this.getActiveHookExecution()
|
||||
if (activeHook) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Run if the API is currently streaming (work happening now)
|
||||
if (this.taskState.isStreaming) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Run if we're waiting for the first chunk (work happening now)
|
||||
if (this.taskState.isWaitingForFirstChunk) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Run if there's active background command (work happening now)
|
||||
if (this.activeBackgroundCommand) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if we're at the resume button state (no active work, just waiting)
|
||||
// Check if we're currently showing the resume button
|
||||
const clineMessages = this.messageStateHandler.getClineMessages()
|
||||
const lastMessage = clineMessages.at(-1)
|
||||
const isAtResumeButton =
|
||||
lastMessage?.type === "ask" && (lastMessage.ask === "resume_task" || lastMessage.ask === "resume_completed_task")
|
||||
|
||||
if (isAtResumeButton) {
|
||||
// At resume button - DON'T run hook because we're just waiting for user input
|
||||
// The resume button appears in two scenarios:
|
||||
// 1. Opening from history (no new work)
|
||||
// 2. After cancelling during active work (but work already stopped)
|
||||
// In both cases, we shouldn't run TaskCancel hook
|
||||
// Already showing resume button from a previous cancellation
|
||||
// OR task was opened from history and user hasn't provided new input yet
|
||||
// In both cases, don't run TaskCancel hook again
|
||||
return false
|
||||
}
|
||||
|
||||
// Not at resume button - we're in the middle of work or just finished something
|
||||
// Run the hook since cancelling would interrupt actual work
|
||||
return true
|
||||
// If we're not at resume button and work was performed in this session,
|
||||
// then TaskCancel should run to properly clean up
|
||||
return this.taskState.didPerformWork
|
||||
}
|
||||
|
||||
async abortTask() {
|
||||
async abortTask(
|
||||
reason: "user_cancel" | "internal_resume" = "user_cancel",
|
||||
): Promise<{ waitingAtResumeButton: boolean; abortReason: "user_cancel" | "internal_resume" }> {
|
||||
const hooksEnabled = featureFlagsService.getHooksEnabled() && this.stateManager.getGlobalSettingsKey("hooksEnabled")
|
||||
|
||||
if (hooksEnabled) {
|
||||
// NEW ENHANCED ABORT FLOW (hooks enabled)
|
||||
// Includes: single-flight guard, TaskCancel hook, resume flow, work tracking
|
||||
|
||||
// Single-flight guard: prevent concurrent abort calls
|
||||
if (this.taskState.isAborting) {
|
||||
// If user is cancelling, override internal resume behavior
|
||||
if (reason === "user_cancel") {
|
||||
this.taskState.abortReason = "user_cancel"
|
||||
}
|
||||
|
||||
// Signal all active hook AbortControllers before returning
|
||||
// This ensures hooks receive cancellation signal when user presses cancel during TaskResume
|
||||
const activeHooks = await this.getActiveHookExecutions()
|
||||
if (activeHooks.length > 0) {
|
||||
try {
|
||||
for (const hook of activeHooks) {
|
||||
hook.abortController.abort()
|
||||
}
|
||||
await this.clearAllActiveHookExecutions()
|
||||
} catch (error) {
|
||||
Logger.error("Failed to cancel hooks during abort", error)
|
||||
await this.clearAllActiveHookExecutions()
|
||||
}
|
||||
}
|
||||
|
||||
return this.taskState.abortPromise!
|
||||
}
|
||||
|
||||
// Mark as aborting and store promise for concurrent callers
|
||||
this.taskState.isAborting = true
|
||||
this.taskState.abortReason = reason // Store the reason for decision logic
|
||||
this.taskState.abortPromise = this._executeAbort()
|
||||
|
||||
try {
|
||||
const result = await this.taskState.abortPromise
|
||||
return result
|
||||
} finally {
|
||||
this.taskState.isAborting = false
|
||||
this.taskState.abortPromise = undefined
|
||||
}
|
||||
} else {
|
||||
// LEGACY ABORT FLOW (hooks disabled)
|
||||
// Simple abort without TaskCancel hooks or enhanced tracking
|
||||
return await this._executeSimpleLegacyAbort()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal abort execution - called only once via the single-flight guard in abortTask()
|
||||
* HOOKS ENABLED VERSION - includes TaskCancel hook and resume flow
|
||||
*/
|
||||
private async _executeAbort(): Promise<{ waitingAtResumeButton: boolean; abortReason: "user_cancel" | "internal_resume" }> {
|
||||
// Flag to determine if we should skip cleanup (when waiting at resume button)
|
||||
let skipCleanup = false
|
||||
let waitingAtResumeButton = false
|
||||
|
||||
try {
|
||||
// PHASE 1: Check if TaskCancel should run BEFORE any cleanup
|
||||
// We must capture this state now because subsequent cleanup will
|
||||
@@ -1340,29 +1658,37 @@ export class Task {
|
||||
// can properly detect the abort state
|
||||
this.taskState.abort = true
|
||||
|
||||
// PHASE 3: Cancel any running hook execution
|
||||
const activeHook = await this.getActiveHookExecution()
|
||||
if (activeHook) {
|
||||
// PHASE 3: Cancel all running hook executions
|
||||
const activeHooks = await this.getActiveHookExecutions()
|
||||
if (activeHooks.length > 0) {
|
||||
try {
|
||||
await this.cancelHookExecution()
|
||||
// Clear activeHookExecution after hook is signaled
|
||||
await this.clearActiveHookExecution()
|
||||
// Cancel all active hooks
|
||||
for (const hook of activeHooks) {
|
||||
hook.abortController.abort()
|
||||
}
|
||||
// Clear all hook state
|
||||
await this.clearAllActiveHookExecutions()
|
||||
} catch (error) {
|
||||
Logger.error("Failed to cancel hook during task abort", error)
|
||||
Logger.error("Failed to cancel hooks during task abort", error)
|
||||
// Still clear state even on error to prevent stuck state
|
||||
await this.clearActiveHookExecution()
|
||||
await this.clearAllActiveHookExecutions()
|
||||
}
|
||||
}
|
||||
|
||||
// PHASE 4: Run TaskCancel hook
|
||||
// This allows the hook UI to appear in the webview
|
||||
// PHASE 4: Run TaskCancel hook if conditions are met
|
||||
// Use the shouldRunTaskCancelHook value we captured in Phase 1
|
||||
const hooksEnabled = featureFlagsService.getHooksEnabled() && this.stateManager.getGlobalSettingsKey("hooksEnabled")
|
||||
if (hooksEnabled && shouldRunTaskCancelHook) {
|
||||
|
||||
// Check if TaskCancel already ran to prevent duplicate execution from sequential abort calls
|
||||
if (hooksEnabled && shouldRunTaskCancelHook && !this.taskState.didRunTaskCancelHook) {
|
||||
// Mark that we're running TaskCancel to prevent duplicate execution
|
||||
this.taskState.didRunTaskCancelHook = true
|
||||
|
||||
try {
|
||||
const { executeHook } = await import("../hooks/hook-executor")
|
||||
|
||||
const taskCancelResult = await executeHook({
|
||||
// Always run TaskCancel hook when conditions are met
|
||||
await executeHook({
|
||||
hookName: "TaskCancel",
|
||||
hookInput: {
|
||||
taskCancel: {
|
||||
@@ -1381,28 +1707,48 @@ export class Task {
|
||||
hooksEnabled,
|
||||
})
|
||||
|
||||
// TaskCancel completed successfully
|
||||
// Present resume button after successful TaskCancel hook
|
||||
const lastClineMessage = this.messageStateHandler
|
||||
.getClineMessages()
|
||||
.slice()
|
||||
.reverse()
|
||||
.find((m) => !(m.ask === "resume_task" || m.ask === "resume_completed_task"))
|
||||
// CRITICAL: Flush all hook status updates to webview before showing resume button
|
||||
// This prevents race condition where hooks show "Running" after completing
|
||||
// The hook executor now ensures all status updates are persisted before returning,
|
||||
// so we don't need artificial delays here
|
||||
await this.messageStateHandler.saveClineMessagesAndUpdateHistory()
|
||||
await this.postStateToWebview()
|
||||
|
||||
let askType: ClineAsk
|
||||
if (lastClineMessage?.ask === "completion_result") {
|
||||
askType = "resume_completed_task"
|
||||
} else {
|
||||
askType = "resume_task"
|
||||
// TaskCancel hook completed - now decide what to do next
|
||||
// Show resume button if:
|
||||
// 1. internal_resume (consecutive mistakes feature), OR
|
||||
// 2. TaskCancel ran (meaning work was done, regardless of abort reason)
|
||||
const shouldShowResumeButton =
|
||||
this.taskState.abortReason === "internal_resume" || this.taskState.didRunTaskCancelHook
|
||||
|
||||
if (shouldShowResumeButton) {
|
||||
// Show resume button - either consecutive mistakes OR TaskCancel ran
|
||||
skipCleanup = true
|
||||
|
||||
// CRITICAL FIX: Clear single-flight guard BEFORE _handleResumeFlow
|
||||
// This allows user to cancel during TaskResume hooks without deadlock
|
||||
// The original abort sequence is complete; _handleResumeFlow starts a NEW user interaction phase
|
||||
this.taskState.isAborting = false
|
||||
this.taskState.abortPromise = undefined
|
||||
|
||||
// Handle resume flow
|
||||
const resumed = await this._handleResumeFlow()
|
||||
if (!resumed) {
|
||||
// User cancelled during TaskResume or UserPromptSubmit - return to resume button state
|
||||
waitingAtResumeButton = true
|
||||
return {
|
||||
waitingAtResumeButton: true,
|
||||
abortReason: (this.taskState.abortReason || "user_cancel") as "user_cancel" | "internal_resume",
|
||||
}
|
||||
}
|
||||
// Task resumed successfully and is running in background
|
||||
// Return successfully without cleanup
|
||||
return {
|
||||
waitingAtResumeButton: false,
|
||||
abortReason: (this.taskState.abortReason || "user_cancel") as "user_cancel" | "internal_resume",
|
||||
}
|
||||
}
|
||||
|
||||
// Present the resume ask - this will show the resume button in the UI
|
||||
// We don't await this because we want to set the abort flag immediately
|
||||
// The ask will be waiting when the user decides to resume
|
||||
this.ask(askType).catch((error) => {
|
||||
// If ask fails (e.g., task was cleared), that's okay - just log it
|
||||
console.log("[TaskCancel] Resume ask failed (task may have been cleared):", error)
|
||||
})
|
||||
// else: No work done (e.g., X button at startup) - just cleanup and close
|
||||
} catch (error) {
|
||||
// TaskCancel hook failed - non-fatal, just log
|
||||
console.error("[TaskCancel Hook] Failed (non-fatal):", error)
|
||||
@@ -1436,25 +1782,126 @@ export class Task {
|
||||
if (this.FocusChainManager) {
|
||||
this.FocusChainManager.dispose()
|
||||
}
|
||||
} finally {
|
||||
// Skip cleanup if we're waiting at resume button
|
||||
if (skipCleanup) {
|
||||
waitingAtResumeButton = true
|
||||
} else {
|
||||
// Release task folder lock
|
||||
if (this.taskLockAcquired) {
|
||||
try {
|
||||
await releaseTaskLock(this.taskId)
|
||||
this.taskLockAcquired = false
|
||||
console.info(`[Task ${this.taskId}] Task lock released`)
|
||||
} catch (error) {
|
||||
console.error(`[Task ${this.taskId}] Failed to release task lock:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// Final state update to notify UI that abort is complete
|
||||
try {
|
||||
await this.postStateToWebview()
|
||||
} catch (error) {
|
||||
Logger.error("Failed to post final state after abort", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return the result, including the reason so Controller can make informed decisions
|
||||
return {
|
||||
waitingAtResumeButton,
|
||||
abortReason: this.taskState.abortReason || "user_cancel",
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy simplified abort implementation for when hooks are disabled.
|
||||
* HOOKS DISABLED VERSION - no TaskCancel hook, no resume flow, no work tracking
|
||||
*
|
||||
* This is the original simple abort flow that:
|
||||
* 1. Sets abort flag
|
||||
* 2. Cancels any active hook (single hook only)
|
||||
* 3. Cleans up resources
|
||||
* 4. Releases locks
|
||||
*
|
||||
* Does NOT include:
|
||||
* - didPerformWork tracking
|
||||
* - shouldRunTaskCancelHook logic
|
||||
* - TaskCancel hook execution
|
||||
* - Resume flow with TaskResume hook
|
||||
* - Single-flight guard (simpler, no concurrent calls expected)
|
||||
*/
|
||||
private async _executeSimpleLegacyAbort(): Promise<{
|
||||
waitingAtResumeButton: boolean
|
||||
abortReason: "user_cancel" | "internal_resume"
|
||||
}> {
|
||||
try {
|
||||
// Set abort flag to stop execution
|
||||
this.taskState.abort = true
|
||||
|
||||
// Cancel any active hook (legacy single-hook only)
|
||||
const activeHooks = await this.getActiveHookExecutions()
|
||||
if (activeHooks.length > 0) {
|
||||
try {
|
||||
for (const hook of activeHooks) {
|
||||
hook.abortController.abort()
|
||||
}
|
||||
await this.clearAllActiveHookExecutions()
|
||||
} catch (error) {
|
||||
Logger.error("Failed to cancel hook during legacy abort", error)
|
||||
await this.clearAllActiveHookExecutions()
|
||||
}
|
||||
}
|
||||
|
||||
// Update UI to reflect abort state
|
||||
try {
|
||||
await this.messageStateHandler.saveClineMessagesAndUpdateHistory()
|
||||
await this.postStateToWebview()
|
||||
} catch (error) {
|
||||
Logger.error("Failed to post state after setting abort flag", error)
|
||||
}
|
||||
|
||||
// Check for incomplete progress
|
||||
if (this.FocusChainManager) {
|
||||
this.FocusChainManager.checkIncompleteProgressOnCompletion()
|
||||
}
|
||||
|
||||
// Clean up resources
|
||||
this.terminalManager.disposeAll()
|
||||
this.urlContentFetcher.closeBrowser()
|
||||
await this.browserSession.dispose()
|
||||
this.clineIgnoreController.dispose()
|
||||
this.fileContextTracker.dispose()
|
||||
await this.diffViewProvider.revertChanges()
|
||||
this.mcpHub.clearNotificationCallback()
|
||||
if (this.FocusChainManager) {
|
||||
this.FocusChainManager.dispose()
|
||||
}
|
||||
} finally {
|
||||
// Release task folder lock
|
||||
if (this.taskLockAcquired) {
|
||||
try {
|
||||
await releaseTaskLock(this.taskId)
|
||||
this.taskLockAcquired = false
|
||||
console.info(`[Task ${this.taskId}] Task lock released`)
|
||||
console.info(`[Task ${this.taskId}] Task lock released (legacy abort)`)
|
||||
} catch (error) {
|
||||
console.error(`[Task ${this.taskId}] Failed to release task lock:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// Final state update to notify UI that abort is complete
|
||||
// Final state update
|
||||
try {
|
||||
await this.postStateToWebview()
|
||||
} catch (error) {
|
||||
Logger.error("Failed to post final state after abort", error)
|
||||
Logger.error("Failed to post final state after legacy abort", error)
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy abort never waits at resume button - always fully cleans up
|
||||
return {
|
||||
waitingAtResumeButton: false,
|
||||
abortReason: "user_cancel",
|
||||
}
|
||||
}
|
||||
|
||||
// Tools
|
||||
@@ -1584,6 +2031,9 @@ export class Task {
|
||||
if (text || (images && images.length > 0) || (files && files.length > 0)) {
|
||||
userFeedback = { text, images, files }
|
||||
}
|
||||
// Continue command execution in background
|
||||
didContinue = true
|
||||
process.continue()
|
||||
} else if (response === "noButtonClicked" && text === COMMAND_CANCEL_TOKEN) {
|
||||
telemetryService.captureTerminalUserIntervention(TerminalUserInterventionAction.CANCELLED)
|
||||
didCancelViaUi = true
|
||||
@@ -1591,19 +2041,6 @@ export class Task {
|
||||
} else {
|
||||
userFeedback = { text, images, files }
|
||||
}
|
||||
didContinue = true
|
||||
process.continue()
|
||||
|
||||
if (didCancelViaUi) {
|
||||
outputBuffer = []
|
||||
outputBufferSize = 0
|
||||
await this.say("command_output", "Command cancelled")
|
||||
}
|
||||
|
||||
// If more output accumulated, flush again
|
||||
if (!didCancelViaUi && outputBuffer.length > 0) {
|
||||
await flushBuffer()
|
||||
}
|
||||
} catch {
|
||||
Logger.error("Error while asking for command output")
|
||||
} finally {
|
||||
@@ -1829,49 +2266,6 @@ export class Task {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a currently running hook execution
|
||||
* @returns true if a hook was cancelled, false if no hook was running
|
||||
*/
|
||||
public async cancelHookExecution(): Promise<boolean> {
|
||||
const activeHook = await this.getActiveHookExecution()
|
||||
if (!activeHook) {
|
||||
return false
|
||||
}
|
||||
|
||||
const { hookName, toolName, messageTs, abortController } = activeHook
|
||||
|
||||
try {
|
||||
// Abort the hook process
|
||||
abortController.abort()
|
||||
|
||||
// Update hook message status to "cancelled"
|
||||
const clineMessages = this.messageStateHandler.getClineMessages()
|
||||
const hookMessageIndex = clineMessages.findIndex((m) => m.ts === messageTs)
|
||||
if (hookMessageIndex !== -1) {
|
||||
const cancelledMetadata = {
|
||||
hookName,
|
||||
toolName,
|
||||
status: "cancelled",
|
||||
exitCode: 130, // Standard SIGTERM exit code
|
||||
}
|
||||
await this.messageStateHandler.updateClineMessage(hookMessageIndex, {
|
||||
text: JSON.stringify(cancelledMetadata),
|
||||
})
|
||||
}
|
||||
|
||||
// Notify UI that hook was cancelled
|
||||
await this.say("hook_output", "\nHook execution cancelled by user")
|
||||
|
||||
// Return success - let caller (abortTask) handle next steps
|
||||
// DON'T call abortTask() here to avoid infinite recursion
|
||||
return true
|
||||
} catch (error) {
|
||||
Logger.error("Failed to cancel hook execution", error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates the disableBrowserTool setting from VSCode configuration to browserSettings
|
||||
*/
|
||||
@@ -2891,7 +3285,7 @@ export class Task {
|
||||
}
|
||||
|
||||
// needs to happen after the say, otherwise the say would fail
|
||||
this.abortTask() // if the stream failed, there's various states the task could be in (i.e. could have streamed some tools the user may have executed), so we just resort to replicating a cancel task
|
||||
await this.abortTask("user_cancel") // if the stream failed, there's various states the task could be in (i.e. could have streamed some tools the user may have executed), so we just resort to replicating a cancel task
|
||||
|
||||
await abortStream("streaming_failed", errorMessage)
|
||||
await this.reinitExistingTaskFromId(this.taskId)
|
||||
|
||||
@@ -67,43 +67,90 @@ function filterPartialToolMessages(messages: ClineMessage[]): ClineMessage[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Combines a single hook message with all subsequent hook_output messages.
|
||||
* Combines a single hook message with all hook_output messages routed to it.
|
||||
*
|
||||
* @param hookMessage The hook message to start combining from
|
||||
* Architecture: Pub-Sub Output Routing
|
||||
* ------------------------------------
|
||||
* Each hook execution gets a dedicated HookOutputChannel that handles routing.
|
||||
* The channel automatically prefixes outputs with the hook's timestamp:
|
||||
* `${hookTimestamp}:${actualOutput}`
|
||||
*
|
||||
* This function trusts the channel's routing and matches outputs by timestamp prefix.
|
||||
* Since channels ensure correct routing, we can scan all messages without worrying
|
||||
* about arrival order - each output is guaranteed to have the correct prefix.
|
||||
*
|
||||
* Benefits:
|
||||
* - Explicit routing via channels (not implicit post-hoc matching)
|
||||
* - Order-independent (outputs can arrive in any sequence)
|
||||
* - Type-safe (channels enforce correct hook-output relationships)
|
||||
* - Timestamps preserved for message identity
|
||||
*
|
||||
* @param hookMessage The hook message to combine with its outputs
|
||||
* @param startIndex The index of the hook message in the messages array
|
||||
* @param messages The full messages array
|
||||
* @param messages The full messages array (scanned completely for this hook's outputs)
|
||||
* @returns Object containing the combined message and the next index to process
|
||||
*/
|
||||
function combineHookWithOutputs(
|
||||
hookMessage: ClineMessage,
|
||||
startIndex: number,
|
||||
messages: ClineMessage[],
|
||||
consumedOutputIndices: Set<number>,
|
||||
): { combined: ClineMessage; nextIndex: number } {
|
||||
let combinedText = hookMessage.text || ""
|
||||
let hasOutput = false
|
||||
let i = startIndex + 1
|
||||
const hookTs = hookMessage.ts
|
||||
|
||||
// Collect all hook_output messages until we hit another hook or end of array
|
||||
while (i < messages.length && messages[i].say !== "hook") {
|
||||
// Scan all remaining messages for outputs routed to this hook
|
||||
// Since HookOutputChannel routes via timestamp prefix, we can trust the routing
|
||||
// and don't need to worry about message order
|
||||
for (let i = startIndex + 1; i < messages.length; i++) {
|
||||
if (messages[i].say === "hook_output") {
|
||||
// Add marker before first output
|
||||
if (!hasOutput) {
|
||||
combinedText += `\n${HOOK_OUTPUT_STRING}`
|
||||
hasOutput = true
|
||||
const outputText = messages[i].text || ""
|
||||
|
||||
// Check if this output belongs to this hook
|
||||
// Format: ${parentTs}:${actualOutput}
|
||||
const colonIndex = outputText.indexOf(":")
|
||||
let belongsToThisHook = false
|
||||
let actualOutput = outputText
|
||||
|
||||
if (colonIndex > 0) {
|
||||
const parentTsStr = outputText.substring(0, colonIndex)
|
||||
const parsedParentTs = parseInt(parentTsStr, 10)
|
||||
|
||||
if (!isNaN(parsedParentTs) && parsedParentTs === hookTs) {
|
||||
// This output belongs to this hook (routed by HookOutputChannel)
|
||||
belongsToThisHook = true
|
||||
actualOutput = outputText.substring(colonIndex + 1)
|
||||
}
|
||||
} else {
|
||||
// No parent timestamp prefix - legacy format
|
||||
// For backward compatibility, only consume if not already consumed by another hook
|
||||
if (!consumedOutputIndices.has(i)) {
|
||||
belongsToThisHook = true
|
||||
}
|
||||
}
|
||||
|
||||
// Append output if not empty
|
||||
const output = messages[i].text || ""
|
||||
if (output.length > 0) {
|
||||
combinedText += "\n" + output
|
||||
if (belongsToThisHook) {
|
||||
// Mark this output as consumed
|
||||
consumedOutputIndices.add(i)
|
||||
|
||||
// Add marker before first output
|
||||
if (!hasOutput) {
|
||||
combinedText += `\n${HOOK_OUTPUT_STRING}`
|
||||
hasOutput = true
|
||||
}
|
||||
|
||||
// Append output if not empty
|
||||
if (actualOutput.length > 0) {
|
||||
combinedText += "\n" + actualOutput
|
||||
}
|
||||
}
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
return {
|
||||
combined: { ...hookMessage, text: combinedText },
|
||||
nextIndex: i,
|
||||
nextIndex: messages.length,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,12 +164,13 @@ function combineHookWithOutputs(
|
||||
function combineAllHooks(messages: ClineMessage[]): ClineMessage[] {
|
||||
// Pass 1: Build map of combined hooks by timestamp
|
||||
const combinedHooksByTs = new Map<number, ClineMessage>()
|
||||
const consumedOutputIndices = new Set<number>()
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
if (messages[i].say === "hook") {
|
||||
const { combined, nextIndex } = combineHookWithOutputs(messages[i], i, messages)
|
||||
const { combined } = combineHookWithOutputs(messages[i], i, messages, consumedOutputIndices)
|
||||
combinedHooksByTs.set(combined.ts, combined)
|
||||
i = nextIndex - 1 // Adjust for loop increment
|
||||
// Don't skip ahead - each hook independently scans all messages
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -81,11 +81,6 @@ interface HookMetadata {
|
||||
* - Running hooks: Always shows pending tool info
|
||||
*/
|
||||
const HookMessage = memo(({ message, CommandOutput }: HookMessageProps) => {
|
||||
// Log when component mounts/updates
|
||||
console.log(
|
||||
`[HOOK-UI RENDER] HookMessage rendering: ${JSON.stringify({ hookName: message.text?.substring(0, 100), ts: message.ts })}`,
|
||||
)
|
||||
|
||||
// Parse hook metadata and output
|
||||
const { metadata, output } = useMemo(() => {
|
||||
const splitMessage = (text: string) => {
|
||||
|
||||
Reference in New Issue
Block a user