source dump of claude code
at main 221 lines 6.8 kB view raw
1import { type FSWatcher, watch } from 'fs' 2import { useEffect, useRef } from 'react' 3import { logForDebugging } from '../utils/debug.js' 4import { 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 14const DEBOUNCE_MS = 1000 15 16type 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 */ 34export 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 */ 197function 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 */ 213function 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}