Filemedium importancesource

useTaskListWatcher.ts

hooks/useTaskListWatcher.ts

222
Lines
6822
Bytes
1
Exports
4
Imports
10
Keywords

What this is

This page documents one file from the repository and includes its full source so you can read it without leaving the docs site.

Beginner explanation

This file is one piece of the larger system. Its name, directory, imports, and exports show where it fits. Start by reading the exports and related files first.

How it is used

Start from the exports list and related files. Those are the easiest clues for where this file fits into the system.

Expert explanation

Architecturally, this file intersects with tasks-background-jobs. It contains 222 lines, 4 detected imports, and 1 detected exports.

Important relationships

Detected exports

  • useTaskListWatcher

Keywords

taskcurrenttaskswatchertasklistidavailabletaskisloadingpromptlogfordebugginguseref

Detected imports

  • fs
  • react
  • ../utils/debug.js
  • ../utils/tasks.js

Source notes

This page embeds the full file contents. Small or leaf files are still indexed honestly instead of being over-explained.

Open parent directory

Full source

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
}