/ src / hooks / useTaskListWatcher.ts
useTaskListWatcher.ts
  1  import { type FSWatcher, watch } from 'fs'
  2  import { useEffect, useRef } from 'react'
  3  import { logForDebugging } from '../utils/debug.js'
  4  import {
  5    claimTask,
  6    DEFAULT_TASKS_MODE_TASK_LIST_ID,
  7    ensureTasksDir,
  8    getTasksDir,
  9    listTasks,
 10    type Task,
 11    updateTask,
 12  } from '../utils/tasks.js'
 13  
 14  const DEBOUNCE_MS = 1000
 15  
 16  type Props = {
 17    /** When undefined, the hook does nothing. The task list id is also used as the agent ID. */
 18    taskListId?: string
 19    isLoading: boolean
 20    /**
 21     * Called when a task is ready to be worked on.
 22     * Returns true if submission succeeded, false if rejected.
 23     */
 24    onSubmitTask: (prompt: string) => boolean
 25  }
 26  
 27  /**
 28   * Hook that watches a task list directory and automatically picks up
 29   * open, unowned tasks to work on.
 30   *
 31   * This enables "tasks mode" where Claude watches for externally-created
 32   * tasks and processes them one at a time.
 33   */
 34  export function useTaskListWatcher({
 35    taskListId,
 36    isLoading,
 37    onSubmitTask,
 38  }: Props): void {
 39    const currentTaskRef = useRef<string | null>(null)
 40    const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
 41  
 42    // Stabilize unstable props via refs so the watcher effect doesn't depend on
 43    // them. isLoading flips every turn, and onSubmitTask's identity changes
 44    // whenever onQuery's deps change. Without this, the watcher effect re-runs
 45    // on every turn, calling watcher.close() + watch() each time — which is a
 46    // trigger for Bun's PathWatcherManager deadlock (oven-sh/bun#27469).
 47    const isLoadingRef = useRef(isLoading)
 48    isLoadingRef.current = isLoading
 49    const onSubmitTaskRef = useRef(onSubmitTask)
 50    onSubmitTaskRef.current = onSubmitTask
 51  
 52    const enabled = taskListId !== undefined
 53    const agentId = taskListId ?? DEFAULT_TASKS_MODE_TASK_LIST_ID
 54  
 55    // checkForTasks reads isLoading and onSubmitTask from refs — always
 56    // up-to-date, no stale closure, and doesn't force a new function identity
 57    // per render. Stored in a ref so the watcher effect can call it without
 58    // depending on it.
 59    const checkForTasksRef = useRef<() => Promise<void>>(async () => {})
 60    checkForTasksRef.current = async () => {
 61      if (!enabled) {
 62        return
 63      }
 64  
 65      // Don't need to submit new tasks if we are already working
 66      if (isLoadingRef.current) {
 67        return
 68      }
 69  
 70      const tasks = await listTasks(taskListId)
 71  
 72      // If we have a current task, check if it's been resolved
 73      if (currentTaskRef.current !== null) {
 74        const currentTask = tasks.find(t => t.id === currentTaskRef.current)
 75        if (!currentTask || currentTask.status === 'completed') {
 76          logForDebugging(
 77            `[TaskListWatcher] Task #${currentTaskRef.current} is marked complete, ready for next task`,
 78          )
 79          currentTaskRef.current = null
 80        } else {
 81          // Still working on current task
 82          return
 83        }
 84      }
 85  
 86      // Find an open task with no owner that isn't blocked
 87      const availableTask = findAvailableTask(tasks)
 88  
 89      if (!availableTask) {
 90        return
 91      }
 92  
 93      logForDebugging(
 94        `[TaskListWatcher] Found available task #${availableTask.id}: ${availableTask.subject}`,
 95      )
 96  
 97      // Claim the task using the task list's agent ID
 98      const result = await claimTask(taskListId, availableTask.id, agentId)
 99  
100      if (!result.success) {
101        logForDebugging(
102          `[TaskListWatcher] Failed to claim task #${availableTask.id}: ${result.reason}`,
103        )
104        return
105      }
106  
107      currentTaskRef.current = availableTask.id
108  
109      // Format the task as a prompt
110      const prompt = formatTaskAsPrompt(availableTask)
111  
112      logForDebugging(
113        `[TaskListWatcher] Submitting task #${availableTask.id} as prompt`,
114      )
115  
116      const submitted = onSubmitTaskRef.current(prompt)
117      if (!submitted) {
118        logForDebugging(
119          `[TaskListWatcher] Failed to submit task #${availableTask.id}, releasing claim`,
120        )
121        // Release the claim
122        await updateTask(taskListId, availableTask.id, { owner: undefined })
123        currentTaskRef.current = null
124      }
125    }
126  
127    // -- Watcher setup
128  
129    // Schedules a check after DEBOUNCE_MS, collapsing rapid fs events.
130    // Shared between the watcher callback and the idle-trigger effect below.
131    const scheduleCheckRef = useRef<() => void>(() => {})
132  
133    useEffect(() => {
134      if (!enabled) return
135  
136      void ensureTasksDir(taskListId)
137      const tasksDir = getTasksDir(taskListId)
138  
139      let watcher: FSWatcher | null = null
140  
141      const debouncedCheck = (): void => {
142        if (debounceTimerRef.current) {
143          clearTimeout(debounceTimerRef.current)
144        }
145        debounceTimerRef.current = setTimeout(
146          ref => void ref.current(),
147          DEBOUNCE_MS,
148          checkForTasksRef,
149        )
150      }
151      scheduleCheckRef.current = debouncedCheck
152  
153      try {
154        watcher = watch(tasksDir, debouncedCheck)
155        watcher.unref()
156        logForDebugging(`[TaskListWatcher] Watching for tasks in ${tasksDir}`)
157      } catch (error) {
158        // fs.watch throws synchronously on ENOENT — ensureTasksDir should have
159        // created the dir, but handle the race gracefully
160        logForDebugging(`[TaskListWatcher] Failed to watch ${tasksDir}: ${error}`)
161      }
162  
163      // Initial check
164      debouncedCheck()
165  
166      return () => {
167        // This cleanup only fires when taskListId changes or on unmount —
168        // never per-turn. That keeps watcher.close() out of the Bun
169        // PathWatcherManager deadlock window.
170        scheduleCheckRef.current = () => {}
171        if (watcher) {
172          watcher.close()
173        }
174        if (debounceTimerRef.current) {
175          clearTimeout(debounceTimerRef.current)
176        }
177      }
178    }, [enabled, taskListId])
179  
180    // Previously, the watcher effect depended on checkForTasks (and transitively
181    // isLoading), so going idle triggered a re-setup whose initial debouncedCheck
182    // would pick up the next task. Preserve that behavior explicitly: when
183    // isLoading drops, schedule a check.
184    useEffect(() => {
185      if (!enabled) return
186      if (isLoading) return
187      scheduleCheckRef.current()
188    }, [enabled, isLoading])
189  }
190  
191  /**
192   * Find an available task that can be worked on:
193   * - Status is 'pending'
194   * - No owner assigned
195   * - Not blocked by any unresolved tasks
196   */
197  function findAvailableTask(tasks: Task[]): Task | undefined {
198    const unresolvedTaskIds = new Set(
199      tasks.filter(t => t.status !== 'completed').map(t => t.id),
200    )
201  
202    return tasks.find(task => {
203      if (task.status !== 'pending') return false
204      if (task.owner) return false
205      // Check all blockers are completed
206      return task.blockedBy.every(id => !unresolvedTaskIds.has(id))
207    })
208  }
209  
210  /**
211   * Format a task as a prompt for Claude to work on.
212   */
213  function formatTaskAsPrompt(task: Task): string {
214    let prompt = `Complete all open tasks. Start with task #${task.id}: \n\n ${task.subject}`
215  
216    if (task.description) {
217      prompt += `\n\n${task.description}`
218    }
219  
220    return prompt
221  }