source dump of claude code
at main 139 lines 6.0 kB view raw
1import { useEffect, useRef } from 'react' 2import { useAppStateStore, useSetAppState } from '../state/AppState.js' 3import { isTerminalTaskStatus } from '../Task.js' 4import { 5 findTeammateTaskByAgentId, 6 injectUserMessageToTeammate, 7} from '../tasks/InProcessTeammateTask/InProcessTeammateTask.js' 8import { isKairosCronEnabled } from '../tools/ScheduleCronTool/prompt.js' 9import type { Message } from '../types/message.js' 10import { getCronJitterConfig } from '../utils/cronJitterConfig.js' 11import { createCronScheduler } from '../utils/cronScheduler.js' 12import { removeCronTasks } from '../utils/cronTasks.js' 13import { logForDebugging } from '../utils/debug.js' 14import { enqueuePendingNotification } from '../utils/messageQueueManager.js' 15import { createScheduledTaskFireMessage } from '../utils/messages.js' 16import { WORKLOAD_CRON } from '../utils/workloadContext.js' 17 18type 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 */ 40export 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 129function 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}