/ src / hooks / useScheduledTasks.ts
useScheduledTasks.ts
  1  import { useEffect, useRef } from 'react'
  2  import { useAppStateStore, useSetAppState } from '../state/AppState.js'
  3  import { isTerminalTaskStatus } from '../Task.js'
  4  import {
  5    findTeammateTaskByAgentId,
  6    injectUserMessageToTeammate,
  7  } from '../tasks/InProcessTeammateTask/InProcessTeammateTask.js'
  8  import { isKairosCronEnabled } from '../tools/ScheduleCronTool/prompt.js'
  9  import type { Message } from '../types/message.js'
 10  import { getCronJitterConfig } from '../utils/cronJitterConfig.js'
 11  import { createCronScheduler } from '../utils/cronScheduler.js'
 12  import { removeCronTasks } from '../utils/cronTasks.js'
 13  import { logForDebugging } from '../utils/debug.js'
 14  import { enqueuePendingNotification } from '../utils/messageQueueManager.js'
 15  import { createScheduledTaskFireMessage } from '../utils/messages.js'
 16  import { WORKLOAD_CRON } from '../utils/workloadContext.js'
 17  
 18  type Props = {
 19    isLoading: boolean
 20    /**
 21     * When true, bypasses the isLoading gate so tasks can enqueue while a
 22     * query is streaming rather than deferring to the next 1s check tick
 23     * after the turn ends. Assistant mode no longer forces --proactive
 24     * (#20425) so isLoading drops between turns like a normal REPL — this
 25     * bypass is now a latency nicety, not a starvation fix. The prompt is
 26     * enqueued at 'later' priority either way and drains between turns.
 27     */
 28    assistantMode?: boolean
 29    setMessages: React.Dispatch<React.SetStateAction<Message[]>>
 30  }
 31  
 32  /**
 33   * REPL wrapper for the cron scheduler. Mounts the scheduler once and tears
 34   * it down on unmount. Fired prompts go into the command queue as 'later'
 35   * priority, which the REPL drains via useCommandQueue between turns.
 36   *
 37   * Scheduler core (timer, file watcher, fire logic) lives in cronScheduler.ts
 38   * so SDK/-p mode can share it — see print.ts for the headless wiring.
 39   */
 40  export function useScheduledTasks({
 41    isLoading,
 42    assistantMode = false,
 43    setMessages,
 44  }: Props): void {
 45    // Latest-value ref so the scheduler's isLoading() getter doesn't capture
 46    // a stale closure. The effect mounts once; isLoading changes every turn.
 47    const isLoadingRef = useRef(isLoading)
 48    isLoadingRef.current = isLoading
 49  
 50    const store = useAppStateStore()
 51    const setAppState = useSetAppState()
 52  
 53    useEffect(() => {
 54      // Runtime gate checked here (not at the hook call site) so the hook
 55      // stays unconditionally mounted — rules-of-hooks forbid wrapping the
 56      // call in a dynamic condition. getFeatureValue_CACHED_WITH_REFRESH
 57      // reads from disk; the 5-min TTL fires a background refetch but the
 58      // effect won't re-run on value flip (assistantMode is the only dep),
 59      // so this guard alone is launch-grain. The mid-session killswitch is
 60      // the isKilled option below — check() polls it every tick.
 61      if (!isKairosCronEnabled()) return
 62  
 63      // System-generated — hidden from queue preview and transcript UI.
 64      // In brief mode, executeForkedSlashCommand runs as a background
 65      // subagent and returns no visible messages. In normal mode,
 66      // isMeta is only propagated for plain-text prompts (via
 67      // processTextPrompt); slash commands like /context:fork do not
 68      // forward isMeta, so their messages remain visible in the
 69      // transcript. This is acceptable since normal mode is not the
 70      // primary use case for scheduled tasks.
 71      const enqueueForLead = (prompt: string) =>
 72        enqueuePendingNotification({
 73          value: prompt,
 74          mode: 'prompt',
 75          priority: 'later',
 76          isMeta: true,
 77          // Threaded through to cc_workload= in the billing-header
 78          // attribution block so the API can serve cron-initiated requests
 79          // at lower QoS when capacity is tight. No human is actively
 80          // waiting on this response.
 81          workload: WORKLOAD_CRON,
 82        })
 83  
 84      const scheduler = createCronScheduler({
 85        // Missed-task surfacing (onFire fallback). Teammate crons are always
 86        // session-only (durable:false) so they never appear in the missed list,
 87        // which is populated from disk at scheduler startup — this path only
 88        // handles team-lead durable crons.
 89        onFire: enqueueForLead,
 90        // Normal fires receive the full CronTask so we can route by agentId.
 91        onFireTask: task => {
 92          if (task.agentId) {
 93            const teammate = findTeammateTaskByAgentId(
 94              task.agentId,
 95              store.getState().tasks,
 96            )
 97            if (teammate && !isTerminalTaskStatus(teammate.status)) {
 98              injectUserMessageToTeammate(teammate.id, task.prompt, setAppState)
 99              return
100            }
101            // Teammate is gone — clean up the orphaned cron so it doesn't keep
102            // firing into nowhere every tick. One-shots would auto-delete on
103            // fire anyway, but recurring crons would loop until auto-expiry.
104            logForDebugging(
105              `[ScheduledTasks] teammate ${task.agentId} gone, removing orphaned cron ${task.id}`,
106            )
107            void removeCronTasks([task.id])
108            return
109          }
110          const msg = createScheduledTaskFireMessage(
111            `Running scheduled task (${formatCronFireTime(new Date())})`,
112          )
113          setMessages(prev => [...prev, msg])
114          enqueueForLead(task.prompt)
115        },
116        isLoading: () => isLoadingRef.current,
117        assistantMode,
118        getJitterConfig: getCronJitterConfig,
119        isKilled: () => !isKairosCronEnabled(),
120      })
121      scheduler.start()
122      return () => scheduler.stop()
123      // assistantMode is stable for the session lifetime; store/setAppState are
124      // stable refs from useSyncExternalStore; setMessages is a stable useCallback.
125      // eslint-disable-next-line react-hooks/exhaustive-deps
126    }, [assistantMode])
127  }
128  
129  function formatCronFireTime(d: Date): string {
130    return d
131      .toLocaleString('en-US', {
132        month: 'short',
133        day: 'numeric',
134        hour: 'numeric',
135        minute: '2-digit',
136      })
137      .replace(/,? at |, /, ' ')
138      .replace(/ ([AP]M)/, (_, ampm) => ampm.toLowerCase())
139  }