import { createOpencodeClient } from '@opencode-ai/sdk/v2'; import { DateTime } from 'luxon'; import parser from 'cron-parser'; import { expandSnippets } from '../opencode/snippets.js'; import { buildGoalIntroText, createSessionGoal } from '../session-goal/create.js'; import { discoverLoops } from './loops.js'; const DEFAULT_GLOBAL_CONCURRENCY = 4; const DEFAULT_PROJECT_CONCURRENCY = 2; const DEFAULT_MAX_RUN_MS = 30 * 60 * 1000; const JITTER_MAX_MS = 2_000; const TASK_TITLE_MAX_LENGTH = 120; const TASK_DUE_SLACK_MS = 5_000; const MAX_TIMER_DELAY_MS = 2_147_483_647; const buildTaskKey = (projectID, taskID) => `${projectID}:${taskID}`; const parseTimeParts = (time) => { const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(typeof time === 'string' ? time : ''); if (!match) { return null; } return { hour: Number(match[1]), minute: Number(match[2]), }; }; const applyTimeToDate = (baseDateTime, time) => { const parsed = parseTimeParts(time); if (!parsed) { return null; } return baseDateTime.set({ hour: parsed.hour, minute: parsed.minute, second: 0, millisecond: 0, }); }; const resolveScheduleTimes = (schedule) => { const times = []; if (Array.isArray(schedule?.times)) { for (const candidate of schedule.times) { if (typeof candidate === 'string' && /^([01]\d|2[0-3]):([0-5]\d)$/.test(candidate)) { times.push(candidate); } } } if (times.length === 0 && typeof schedule?.time === 'string' && /^([01]\d|2[0-3]):([0-5]\d)$/.test(schedule.time)) { times.push(schedule.time); } return Array.from(new Set(times)).sort((a, b) => a.localeCompare(b)); }; const weekdayAsZeroBased = (dateTime) => { if (!dateTime || typeof dateTime.weekday !== 'number') { return null; } return dateTime.weekday % 7; }; const safeErrorMessage = (error, maxLength = 2_000) => { const raw = error instanceof Error ? (error.message || String(error)) : String(error ?? 'Unknown error'); const trimmed = raw.trim(); if (!trimmed) { return 'Unknown error'; } return trimmed.length > maxLength ? trimmed.slice(0, maxLength) : trimmed; }; export const parseScheduledCommandPrompt = (prompt) => { if (typeof prompt !== 'string') { return null; } const trimmed = prompt.trim(); if (!trimmed.startsWith('/')) { return null; } const firstLine = trimmed.split(/\r?\n/, 1)[0] || ''; const [head, ...tail] = firstLine.split(/\s+/); const commandName = (head || '').slice(1).trim(); if (!commandName) { return null; } return { command: commandName, arguments: tail.join(' ').trim(), }; }; export const expandCommandGoalObjective = (template, argumentsText) => { if (typeof template !== 'string' || !template.trim()) { return null; } const rawArguments = String(argumentsText ?? ''); if (template.includes('$ARGUMENTS')) { return template.replaceAll('$ARGUMENTS', rawArguments); } const positions = [...template.matchAll(/\$(\d+)/g)].map((match) => Number(match[1])); if (positions.length > 0) { const parsedArguments = [...rawArguments.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)] .map((match) => match[1] ?? match[2] ?? match[3] ?? ''); const lastPosition = Math.max(...positions); return template.replace(/\$(\d+)/g, (_match, value) => { const position = Number(value); return position === lastPosition ? parsedArguments.slice(position - 1).join(' ') : (parsedArguments[position - 1] ?? ''); }); } return rawArguments ? `${template}\n\n${rawArguments}` : template; }; export const computeNextRunAt = (task, nowMs = Date.now()) => { if (!task?.enabled) { return null; } const schedule = task.schedule; if (!schedule || typeof schedule !== 'object') { return null; } const zone = typeof schedule.timezone === 'string' && schedule.timezone.trim().length > 0 ? schedule.timezone.trim() : DateTime.local().zoneName; const now = DateTime.fromMillis(nowMs, { zone }); if (!now.isValid) { return null; } if (schedule.kind === 'daily') { const times = resolveScheduleTimes(schedule); if (times.length === 0) { return null; } const minAllowed = now.plus({ milliseconds: TASK_DUE_SLACK_MS }); for (const time of times) { const candidateToday = applyTimeToDate(now, time); if (!candidateToday || !candidateToday.isValid) { continue; } if (candidateToday > minAllowed) { return candidateToday.toMillis(); } } const tomorrow = now.plus({ days: 1 }); const firstTomorrow = applyTimeToDate(tomorrow, times[0]); return firstTomorrow?.isValid ? firstTomorrow.toMillis() : null; } if (schedule.kind === 'weekly') { if (!Array.isArray(schedule.weekdays) || schedule.weekdays.length === 0) { return null; } const times = resolveScheduleTimes(schedule); if (times.length === 0) { return null; } const weekdaysSet = new Set(schedule.weekdays); const minAllowed = now.plus({ milliseconds: TASK_DUE_SLACK_MS }); for (let dayOffset = 0; dayOffset <= 14; dayOffset += 1) { const dayCandidate = now.plus({ days: dayOffset }); const zeroBasedWeekday = weekdayAsZeroBased(dayCandidate); if (zeroBasedWeekday === null || !weekdaysSet.has(zeroBasedWeekday)) { continue; } for (const time of times) { const withTime = applyTimeToDate(dayCandidate, time); if (!withTime || !withTime.isValid) { continue; } if (withTime > minAllowed) { return withTime.toMillis(); } } } return null; } if (schedule.kind === 'once') { if (typeof schedule.date !== 'string' || typeof schedule.time !== 'string') { return null; } const parsed = DateTime.fromFormat( `${schedule.date} ${schedule.time}`, 'yyyy-LL-dd HH:mm', { zone }, ); if (!parsed.isValid) { return null; } const minAllowed = now.plus({ milliseconds: TASK_DUE_SLACK_MS }); if (parsed <= minAllowed) { return null; } return parsed.toMillis(); } if (schedule.kind === 'cron') { try { const iterator = parser.parseExpression(schedule.cron, { tz: zone, currentDate: new Date(nowMs), }); return iterator.next().getTime(); } catch { return null; } } return null; }; export const formatScheduledSessionTitle = (task, nowMs = Date.now()) => { const timezone = typeof task?.schedule?.timezone === 'string' && task.schedule.timezone.trim().length > 0 ? task.schedule.timezone.trim() : DateTime.local().zoneName; const stamp = DateTime.fromMillis(nowMs, { zone: timezone }).toFormat('yyyy-LL-dd HH:mm'); const taskName = typeof task?.name === 'string' && task.name.trim().length > 0 ? task.name.trim() : 'Scheduled task'; const suffix = ` ${stamp}`; const maxTaskNameLength = Math.max(1, TASK_TITLE_MAX_LENGTH - suffix.length); const trimmedName = taskName.length > maxTaskNameLength ? taskName.slice(0, maxTaskNameLength) : taskName; return `${trimmedName}${suffix}`; }; export const createScheduledTasksRuntime = (deps) => { const { projectConfigRuntime, listProjects, buildOpenCodeUrl, getOpenCodeAuthHeaders, waitForOpenCodeReady, emitTaskRunEvent, setSessionAutoAccept, logger = console, maxGlobalConcurrency = DEFAULT_GLOBAL_CONCURRENCY, maxProjectConcurrency = DEFAULT_PROJECT_CONCURRENCY, maxRunDurationMs = DEFAULT_MAX_RUN_MS, } = deps; let started = false; const tasksByProject = new Map(); const projectPathByID = new Map(); const timersByTaskKey = new Map(); const queuedTaskKeys = new Set(); const runningTaskKeys = new Set(); const runningCountByProject = new Map(); let runningGlobalCount = 0; const queue = []; const clearTimerForKey = (taskKey) => { const timer = timersByTaskKey.get(taskKey); if (timer) { clearTimeout(timer); timersByTaskKey.delete(taskKey); } }; const clearProjectTimers = (projectID) => { const tasks = tasksByProject.get(projectID); if (!tasks) { return; } for (const task of tasks.values()) { clearTimerForKey(buildTaskKey(projectID, task.id)); queuedTaskKeys.delete(buildTaskKey(projectID, task.id)); } }; const setProjectTasks = (projectID, tasks) => { clearProjectTimers(projectID); const taskMap = new Map(); for (const task of tasks) { taskMap.set(task.id, task); } tasksByProject.set(projectID, taskMap); }; const scheduleTask = (projectID, taskID, nextRunAt) => { const taskKey = buildTaskKey(projectID, taskID); clearTimerForKey(taskKey); if (!started) { return; } if (!Number.isFinite(nextRunAt) || nextRunAt <= 0) { return; } const delayBase = Math.max(0, Math.round(nextRunAt - Date.now())); const jitter = Math.floor(Math.random() * (JITTER_MAX_MS + 1)); const delay = delayBase + jitter; const boundedDelay = Math.min(delay, MAX_TIMER_DELAY_MS); const timer = setTimeout(async () => { if (delay > MAX_TIMER_DELAY_MS) { scheduleTask(projectID, taskID, nextRunAt); return; } clearTimerForKey(taskKey); const taskMap = tasksByProject.get(projectID); const task = taskMap?.get(taskID); if (!task || !task.enabled) { return; } queueTaskRun(projectID, taskID, 'scheduled'); pumpQueue(); }, boundedDelay); timersByTaskKey.set(taskKey, timer); }; const updateInMemoryTask = (projectID, nextTask) => { if (!nextTask) { return; } const taskMap = tasksByProject.get(projectID); if (!taskMap) { return; } taskMap.set(nextTask.id, nextTask); }; const syncTaskSchedule = async (projectID, task) => { if (!task) { return; } const nextRunAt = computeNextRunAt(task, Date.now()); const statePatch = { nextRunAt: Number.isFinite(nextRunAt) ? nextRunAt : undefined, updatedAt: Date.now(), }; const result = await projectConfigRuntime.updateScheduledTaskState(projectID, task.id, statePatch); if (result.task) { updateInMemoryTask(projectID, result.task); if (result.task.enabled && Number.isFinite(result.task.state?.nextRunAt)) { scheduleTask(projectID, result.task.id, result.task.state.nextRunAt); } } }; const ensureProjectPath = async (projectID) => { if (projectPathByID.has(projectID)) { return projectPathByID.get(projectID) || null; } try { const projects = await listProjects(); const project = projects.find((item) => item?.id === projectID && item?.path); if (project?.path) { projectPathByID.set(projectID, project.path); return project.path; } } catch { } return null; }; const syncProject = async (projectID) => { await ensureProjectPath(projectID); const projectPath = projectPathByID.get(projectID) || null; let tasks; if (projectPath) { // Reconcile `.agents/loops` definitions with the persisted task list: // loop files are authoritative while present, removed files unschedule // their task, and runtime state is preserved (see loops.js). const loops = await discoverLoops(projectPath); tasks = await projectConfigRuntime.reconcileLoopTasks(projectID, loops); } else { tasks = await projectConfigRuntime.listScheduledTasks(projectID); } setProjectTasks(projectID, tasks); for (const task of tasks) { await syncTaskSchedule(projectID, task); } return tasks; }; const syncAllProjects = async () => { const projects = await listProjects(); const activeProjectIDs = new Set(); projectPathByID.clear(); for (const project of projects) { if (!project?.id || !project?.path) { continue; } activeProjectIDs.add(project.id); projectPathByID.set(project.id, project.path); } for (const existingProjectID of Array.from(tasksByProject.keys())) { if (!activeProjectIDs.has(existingProjectID)) { clearProjectTimers(existingProjectID); tasksByProject.delete(existingProjectID); } } for (const projectID of activeProjectIDs) { await syncProject(projectID); } }; const queueTaskRun = (projectID, taskID, reason) => { const taskKey = buildTaskKey(projectID, taskID); if (queuedTaskKeys.has(taskKey) || runningTaskKeys.has(taskKey)) { return; } queuedTaskKeys.add(taskKey); queue.push({ projectID, taskID, reason }); }; const canRunTask = (projectID) => { if (runningGlobalCount >= maxGlobalConcurrency) { return false; } const projectRunning = runningCountByProject.get(projectID) || 0; return projectRunning < maxProjectConcurrency; }; const buildPromptAsyncPayload = (task, projectPath) => ({ model: { providerID: task.execution.providerID, modelID: task.execution.modelID, }, ...(task.execution.agent ? { agent: task.execution.agent } : {}), ...(task.execution.variant ? { variant: task.execution.variant } : {}), parts: [ { type: 'text', text: expandSnippets(task.execution.prompt, projectPath), }, ...(task.execution.goalEnabled ? [{ type: 'text', text: buildGoalIntroText(task.execution.goalTokenBudget), synthetic: true }] : []), ], }); const runPromptAsync = async ({ baseUrl, authHeaders, sessionID, projectPath, task }) => { const promptUrl = new URL(`${baseUrl}/session/${encodeURIComponent(sessionID)}/prompt_async`); promptUrl.searchParams.set('directory', projectPath); const response = await fetch(promptUrl.toString(), { method: 'POST', headers: { ...authHeaders, 'content-type': 'application/json', accept: 'application/json', }, body: JSON.stringify(buildPromptAsyncPayload(task, projectPath)), }); if (!response.ok) { const body = await response.text().catch(() => ''); throw new Error(`prompt_async failed (${response.status})${body ? `: ${body}` : ''}`); } }; const resolveScheduledCommand = async ({ client, projectPath, task }) => { const parsed = parseScheduledCommandPrompt(task?.execution?.prompt); if (!parsed) { return null; } let commands = []; try { const response = await client.command.list({ directory: projectPath }); commands = Array.isArray(response?.data) ? response.data : []; } catch { return null; } const command = commands.find((candidate) => candidate?.name === parsed.command); return command ? { ...parsed, template: command.template } : null; }; const runScheduledCommand = async ({ client, projectPath, sessionID, task, command }) => { await client.session.command({ sessionID, directory: projectPath, command: command.command, arguments: command.arguments, ...(task.execution.agent ? { agent: task.execution.agent } : {}), model: `${task.execution.providerID}/${task.execution.modelID}`, ...(task.execution.variant ? { variant: task.execution.variant } : {}), }); }; const runTaskWithWatchdog = async (projectID, task, reason) => { const startedAt = Date.now(); const title = formatScheduledSessionTitle(task, startedAt); const projectPath = projectPathByID.get(projectID); if (!projectPath) { throw new Error('project path is unavailable'); } if (typeof waitForOpenCodeReady === 'function') { await waitForOpenCodeReady(10_000, 250); } const baseUrl = buildOpenCodeUrl('/', '').replace(/\/$/, ''); const authHeaders = getOpenCodeAuthHeaders(); const client = createOpencodeClient({ baseUrl, headers: authHeaders, }); const sessionResponse = await client.session.create({ directory: projectPath, title, }); const sessionID = sessionResponse?.data?.id; if (!sessionID) { throw new Error('failed to create session'); } try { emitTaskRunEvent?.({ projectID, taskID: task.id, ranAt: startedAt, status: 'running', sessionID, }); } catch { } if (task.execution.permissionAutoAccept && typeof setSessionAutoAccept === 'function') { // Enroll before the prompt goes out so the very first permission request // is already auto-approved. Enrollment failure must not kill the run — // the task still executes, permissions just wait for the user. try { await setSessionAutoAccept(sessionID, true, projectPath); } catch (error) { logger.warn?.('[scheduled-tasks] failed to enable permission auto-accept for session', sessionID, error?.message ?? error); } } const scheduledCommand = await resolveScheduledCommand({ client, projectPath, task }); if (task.execution.goalEnabled) { const commandObjective = scheduledCommand ? expandCommandGoalObjective(scheduledCommand.template, scheduledCommand.arguments) : null; await createSessionGoal({ baseUrl, authHeaders, sessionID, directory: projectPath, objective: commandObjective ?? expandSnippets(task.execution.prompt, projectPath), tokenBudget: task.execution.goalTokenBudget, providerID: task.execution.providerID, modelID: task.execution.modelID, onWarning: (message, error) => console.warn(`[scheduled-tasks] ${message}:`, error?.message || error), }); } if (scheduledCommand) { await runScheduledCommand({ client, projectPath, sessionID, task, command: scheduledCommand }); } else { await runPromptAsync({ baseUrl, authHeaders, sessionID, projectPath, task, }); } const finishedAt = Date.now(); return { sessionID, durationMs: Math.max(0, finishedAt - startedAt), reason, startedAt, finishedAt, }; }; const runTask = async (projectID, taskID, reason) => { const taskMap = tasksByProject.get(projectID); const task = taskMap?.get(taskID); if (!task || !task.enabled) { return { ok: false, skipped: true }; } const taskKey = buildTaskKey(projectID, taskID); if (runningTaskKeys.has(taskKey)) { return { ok: false, running: true }; } runningTaskKeys.add(taskKey); runningGlobalCount += 1; runningCountByProject.set(projectID, (runningCountByProject.get(projectID) || 0) + 1); const runStartedAt = Date.now(); await projectConfigRuntime.updateScheduledTaskState(projectID, taskID, { lastRunAt: runStartedAt, lastStatus: 'running', lastError: undefined, updatedAt: runStartedAt, }).then((result) => { if (result.task) { updateInMemoryTask(projectID, result.task); } }); let status = 'success'; let sessionID; let durationMs = 0; let errorMessage; try { const runPromise = runTaskWithWatchdog(projectID, task, reason); let timeoutID; const timeoutPromise = new Promise((_, reject) => { timeoutID = setTimeout(() => { reject(new Error('scheduled task run timed out')); }, maxRunDurationMs); }); const result = await Promise.race([runPromise, timeoutPromise]).finally(() => { if (timeoutID) { clearTimeout(timeoutID); } }); sessionID = result.sessionID; durationMs = result.durationMs; status = 'success'; logger.info?.( '[ScheduledTasks] run completed', { projectID, taskID, status, reason, sessionID, durationMs } ); } catch (error) { status = 'error'; errorMessage = safeErrorMessage(error); logger.warn?.('[ScheduledTasks] run failed', { projectID, taskID, reason, status, error: errorMessage, }); } const finishedAt = Date.now(); if (!durationMs) { durationMs = Math.max(0, finishedAt - runStartedAt); } let latestTask = (tasksByProject.get(projectID)?.get(taskID)) || task; const shouldConsumeOneTimeTask = latestTask?.schedule?.kind === 'once' && reason === 'scheduled'; if (shouldConsumeOneTimeTask && latestTask?.enabled) { try { const consumed = await projectConfigRuntime.upsertScheduledTask(projectID, { ...latestTask, enabled: false, }); latestTask = consumed.task || latestTask; updateInMemoryTask(projectID, latestTask); } catch (consumeError) { logger.warn?.('[ScheduledTasks] failed to consume one-time task', { projectID, taskID, error: safeErrorMessage(consumeError), }); } } const nextRunAt = computeNextRunAt(latestTask, finishedAt); const statePatch = { lastStatus: status, lastDurationMs: durationMs, lastError: status === 'error' ? errorMessage : undefined, lastSessionId: status === 'success' ? sessionID : undefined, nextRunAt: Number.isFinite(nextRunAt) ? nextRunAt : undefined, updatedAt: finishedAt, }; const stateResult = await projectConfigRuntime.updateScheduledTaskState(projectID, taskID, statePatch); if (stateResult.task) { updateInMemoryTask(projectID, stateResult.task); if (stateResult.task.enabled && Number.isFinite(stateResult.task.state?.nextRunAt)) { scheduleTask(projectID, taskID, stateResult.task.state.nextRunAt); } } try { emitTaskRunEvent?.({ projectID, taskID, ranAt: finishedAt, status, ...(sessionID ? { sessionID } : {}), }); } catch { } runningTaskKeys.delete(taskKey); runningGlobalCount = Math.max(0, runningGlobalCount - 1); const nextProjectCount = Math.max(0, (runningCountByProject.get(projectID) || 1) - 1); if (nextProjectCount === 0) { runningCountByProject.delete(projectID); } else { runningCountByProject.set(projectID, nextProjectCount); } return { ok: status === 'success', status, sessionID, task: stateResult.task || null, error: errorMessage, }; }; const pumpQueue = () => { if (!started) { return; } let consumed = false; for (let index = 0; index < queue.length; index += 1) { const item = queue[index]; if (!canRunTask(item.projectID)) { continue; } queue.splice(index, 1); index -= 1; const taskKey = buildTaskKey(item.projectID, item.taskID); queuedTaskKeys.delete(taskKey); consumed = true; void runTask(item.projectID, item.taskID, item.reason).finally(() => { pumpQueue(); }); } if (!consumed && queue.length > 0) { return; } }; const runNow = async (projectID, taskID) => { const taskKey = buildTaskKey(projectID, taskID); if (runningTaskKeys.has(taskKey)) { return { ok: false, running: true, error: 'task is already running', }; } if (queuedTaskKeys.has(taskKey)) { return { ok: false, queued: true, error: 'task is already queued', }; } return runTask(projectID, taskID, 'manual'); }; const start = async () => { if (started) { return; } started = true; await syncAllProjects(); }; const stop = () => { if (!started) { return; } started = false; for (const timer of timersByTaskKey.values()) { clearTimeout(timer); } timersByTaskKey.clear(); queuedTaskKeys.clear(); queue.length = 0; }; const getStatus = () => { let enabledCount = 0; for (const taskMap of tasksByProject.values()) { for (const task of taskMap.values()) { if (task?.enabled) { enabledCount += 1; } } } const runningCount = runningTaskKeys.size; return { hasEnabledScheduledTasks: enabledCount > 0, hasRunningScheduledTasks: runningCount > 0, enabledScheduledTasksCount: enabledCount, runningScheduledTasksCount: runningCount, }; }; return { start, stop, syncAllProjects, syncProject, runNow, getStatus, }; };