init
This commit is contained in:
@@ -0,0 +1,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
|
||||
}
|
||||
Reference in New Issue
Block a user