140 lines
5.8 KiB
TypeScript
140 lines
5.8 KiB
TypeScript
import { useEffect, useRef } from 'react'
|
|
import { useAppStateStore, useSetAppState } from '../state/AppState.js'
|
|
import { isTerminalTaskStatus } from '../Task.js'
|
|
import {
|
|
findTeammateTaskByAgentId,
|
|
injectUserMessageToTeammate,
|
|
} from '../tasks/InProcessTeammateTask/InProcessTeammateTask.js'
|
|
import { isKairosCronEnabled } from '../tools/ScheduleCronTool/prompt.js'
|
|
import type { Message } from '../types/message.js'
|
|
import { getCronJitterConfig } from '../utils/cronJitterConfig.js'
|
|
import { createCronScheduler } from '../utils/cronScheduler.js'
|
|
import { removeCronTasks } from '../utils/cronTasks.js'
|
|
import { logForDebugging } from '../utils/debug.js'
|
|
import { enqueuePendingNotification } from '../utils/messageQueueManager.js'
|
|
import { createScheduledTaskFireMessage } from '../utils/messages.js'
|
|
import { WORKLOAD_CRON } from '../utils/workloadContext.js'
|
|
|
|
type Props = {
|
|
isLoading: boolean
|
|
/**
|
|
* When true, bypasses the isLoading gate so tasks can enqueue while a
|
|
* query is streaming rather than deferring to the next 1s check tick
|
|
* after the turn ends. Assistant mode no longer forces --proactive
|
|
* (#20425) so isLoading drops between turns like a normal REPL — this
|
|
* bypass is now a latency nicety, not a starvation fix. The prompt is
|
|
* enqueued at 'later' priority either way and drains between turns.
|
|
*/
|
|
assistantMode?: boolean
|
|
setMessages: React.Dispatch<React.SetStateAction<Message[]>>
|
|
}
|
|
|
|
/**
|
|
* REPL wrapper for the cron scheduler. Mounts the scheduler once and tears
|
|
* it down on unmount. Fired prompts go into the command queue as 'later'
|
|
* priority, which the REPL drains via useCommandQueue between turns.
|
|
*
|
|
* Scheduler core (timer, file watcher, fire logic) lives in cronScheduler.ts
|
|
* so SDK/-p mode can share it — see print.ts for the headless wiring.
|
|
*/
|
|
export function useScheduledTasks({
|
|
isLoading,
|
|
assistantMode = false,
|
|
setMessages,
|
|
}: Props): void {
|
|
// Latest-value ref so the scheduler's isLoading() getter doesn't capture
|
|
// a stale closure. The effect mounts once; isLoading changes every turn.
|
|
const isLoadingRef = useRef(isLoading)
|
|
isLoadingRef.current = isLoading
|
|
|
|
const store = useAppStateStore()
|
|
const setAppState = useSetAppState()
|
|
|
|
useEffect(() => {
|
|
// Runtime gate checked here (not at the hook call site) so the hook
|
|
// stays unconditionally mounted — rules-of-hooks forbid wrapping the
|
|
// call in a dynamic condition. getFeatureValue_CACHED_WITH_REFRESH
|
|
// reads from disk; the 5-min TTL fires a background refetch but the
|
|
// effect won't re-run on value flip (assistantMode is the only dep),
|
|
// so this guard alone is launch-grain. The mid-session killswitch is
|
|
// the isKilled option below — check() polls it every tick.
|
|
if (!isKairosCronEnabled()) return
|
|
|
|
// System-generated — hidden from queue preview and transcript UI.
|
|
// In brief mode, executeForkedSlashCommand runs as a background
|
|
// subagent and returns no visible messages. In normal mode,
|
|
// isMeta is only propagated for plain-text prompts (via
|
|
// processTextPrompt); slash commands like /context:fork do not
|
|
// forward isMeta, so their messages remain visible in the
|
|
// transcript. This is acceptable since normal mode is not the
|
|
// primary use case for scheduled tasks.
|
|
const enqueueForLead = (prompt: string) =>
|
|
enqueuePendingNotification({
|
|
value: prompt,
|
|
mode: 'prompt',
|
|
priority: 'later',
|
|
isMeta: true,
|
|
// Threaded through to cc_workload= in the billing-header
|
|
// attribution block so the API can serve cron-initiated requests
|
|
// at lower QoS when capacity is tight. No human is actively
|
|
// waiting on this response.
|
|
workload: WORKLOAD_CRON,
|
|
})
|
|
|
|
const scheduler = createCronScheduler({
|
|
// Missed-task surfacing (onFire fallback). Teammate crons are always
|
|
// session-only (durable:false) so they never appear in the missed list,
|
|
// which is populated from disk at scheduler startup — this path only
|
|
// handles team-lead durable crons.
|
|
onFire: enqueueForLead,
|
|
// Normal fires receive the full CronTask so we can route by agentId.
|
|
onFireTask: task => {
|
|
if (task.agentId) {
|
|
const teammate = findTeammateTaskByAgentId(
|
|
task.agentId,
|
|
store.getState().tasks,
|
|
)
|
|
if (teammate && !isTerminalTaskStatus(teammate.status)) {
|
|
injectUserMessageToTeammate(teammate.id, task.prompt, setAppState)
|
|
return
|
|
}
|
|
// Teammate is gone — clean up the orphaned cron so it doesn't keep
|
|
// firing into nowhere every tick. One-shots would auto-delete on
|
|
// fire anyway, but recurring crons would loop until auto-expiry.
|
|
logForDebugging(
|
|
`[ScheduledTasks] teammate ${task.agentId} gone, removing orphaned cron ${task.id}`,
|
|
)
|
|
void removeCronTasks([task.id])
|
|
return
|
|
}
|
|
const msg = createScheduledTaskFireMessage(
|
|
`Running scheduled task (${formatCronFireTime(new Date())})`,
|
|
)
|
|
setMessages(prev => [...prev, msg])
|
|
enqueueForLead(task.prompt)
|
|
},
|
|
isLoading: () => isLoadingRef.current,
|
|
assistantMode,
|
|
getJitterConfig: getCronJitterConfig,
|
|
isKilled: () => !isKairosCronEnabled(),
|
|
})
|
|
scheduler.start()
|
|
return () => scheduler.stop()
|
|
// assistantMode is stable for the session lifetime; store/setAppState are
|
|
// stable refs from useSyncExternalStore; setMessages is a stable useCallback.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [assistantMode])
|
|
}
|
|
|
|
function formatCronFireTime(d: Date): string {
|
|
return d
|
|
.toLocaleString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
})
|
|
.replace(/,? at |, /, ' ')
|
|
.replace(/ ([AP]M)/, (_, ampm) => ampm.toLowerCase())
|
|
}
|