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