/ tasks / stopTask.ts
stopTask.ts
  1  // Shared logic for stopping a running task.
  2  // Used by TaskStopTool (LLM-invoked) and SDK stop_task control request.
  3  
  4  import type { AppState } from '../state/AppState.js'
  5  import type { TaskStateBase } from '../Task.js'
  6  import { getTaskByType } from '../tasks.js'
  7  import { emitTaskTerminatedSdk } from '../utils/sdkEventQueue.js'
  8  import { isLocalShellTask } from './LocalShellTask/guards.js'
  9  
 10  export class StopTaskError extends Error {
 11    constructor(
 12      message: string,
 13      public readonly code: 'not_found' | 'not_running' | 'unsupported_type',
 14    ) {
 15      super(message)
 16      this.name = 'StopTaskError'
 17    }
 18  }
 19  
 20  type StopTaskContext = {
 21    getAppState: () => AppState
 22    setAppState: (f: (prev: AppState) => AppState) => void
 23  }
 24  
 25  type StopTaskResult = {
 26    taskId: string
 27    taskType: string
 28    command: string | undefined
 29  }
 30  
 31  /**
 32   * Look up a task by ID, validate it is running, kill it, and mark it as notified.
 33   *
 34   * Throws {@link StopTaskError} when the task cannot be stopped (not found,
 35   * not running, or unsupported type). Callers can inspect `error.code` to
 36   * distinguish the failure reason.
 37   */
 38  export async function stopTask(
 39    taskId: string,
 40    context: StopTaskContext,
 41  ): Promise<StopTaskResult> {
 42    const { getAppState, setAppState } = context
 43    const appState = getAppState()
 44    const task = appState.tasks?.[taskId] as TaskStateBase | undefined
 45  
 46    if (!task) {
 47      throw new StopTaskError(`No task found with ID: ${taskId}`, 'not_found')
 48    }
 49  
 50    if (task.status !== 'running') {
 51      throw new StopTaskError(
 52        `Task ${taskId} is not running (status: ${task.status})`,
 53        'not_running',
 54      )
 55    }
 56  
 57    const taskImpl = getTaskByType(task.type)
 58    if (!taskImpl) {
 59      throw new StopTaskError(
 60        `Unsupported task type: ${task.type}`,
 61        'unsupported_type',
 62      )
 63    }
 64  
 65    await taskImpl.kill(taskId, setAppState)
 66  
 67    // Bash: suppress the "exit code 137" notification (noise). Agent tasks: don't
 68    // suppress — the AbortError catch sends a notification carrying
 69    // extractPartialResult(agentMessages), which is the payload not noise.
 70    if (isLocalShellTask(task)) {
 71      let suppressed = false
 72      setAppState(prev => {
 73        const prevTask = prev.tasks[taskId]
 74        if (!prevTask || prevTask.notified) {
 75          return prev
 76        }
 77        suppressed = true
 78        return {
 79          ...prev,
 80          tasks: {
 81            ...prev.tasks,
 82            [taskId]: { ...prevTask, notified: true },
 83          },
 84        }
 85      })
 86      // Suppressing the XML notification also suppresses print.ts's parsed
 87      // task_notification SDK event — emit it directly so SDK consumers see
 88      // the task close.
 89      if (suppressed) {
 90        emitTaskTerminatedSdk(taskId, 'stopped', {
 91          toolUseId: task.toolUseId,
 92          summary: task.description,
 93        })
 94      }
 95    }
 96  
 97    const command = isLocalShellTask(task) ? task.command : task.description
 98  
 99    return { taskId, taskType: task.type, command }
100  }