2026-04-16 15:55:08 +03:00
|
|
|
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
|
|
|
|
|
import { DateTime } from 'luxon';
|
|
|
|
|
import parser from 'cron-parser';
|
2026-05-21 20:00:35 +03:00
|
|
|
import { expandSnippets } from '../opencode/snippets.js';
|
2026-07-24 21:54:28 +03:00
|
|
|
import { buildGoalIntroText, createSessionGoal } from '../session-goal/create.js';
|
2026-08-05 14:11:28 +03:00
|
|
|
import { discoverLoops } from './loops.js';
|
2026-04-16 15:55:08 +03:00
|
|
|
|
|
|
|
|
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(),
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-29 00:47:29 +03:00
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-16 15:55:08 +03:00
|
|
|
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,
|
2026-07-20 23:07:01 +03:00
|
|
|
setSessionAutoAccept,
|
2026-04-16 15:55:08 +03:00
|
|
|
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);
|
|
|
|
|
|
2026-05-13 03:32:16 -04:00
|
|
|
if (!started) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 15:55:08 +03:00
|
|
|
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);
|
2026-08-05 14:11:28 +03:00
|
|
|
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);
|
|
|
|
|
}
|
2026-04-16 15:55:08 +03:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-21 20:00:35 +03:00
|
|
|
const buildPromptAsyncPayload = (task, projectPath) => ({
|
2026-04-16 15:55:08 +03:00
|
|
|
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',
|
2026-05-21 20:00:35 +03:00
|
|
|
text: expandSnippets(task.execution.prompt, projectPath),
|
2026-04-16 15:55:08 +03:00
|
|
|
},
|
2026-07-12 01:23:22 +03:00
|
|
|
...(task.execution.goalEnabled
|
|
|
|
|
? [{ type: 'text', text: buildGoalIntroText(task.execution.goalTokenBudget), synthetic: true }]
|
|
|
|
|
: []),
|
2026-04-16 15:55:08 +03:00
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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',
|
|
|
|
|
},
|
2026-05-21 20:00:35 +03:00
|
|
|
body: JSON.stringify(buildPromptAsyncPayload(task, projectPath)),
|
2026-04-16 15:55:08 +03:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
const body = await response.text().catch(() => '');
|
|
|
|
|
throw new Error(`prompt_async failed (${response.status})${body ? `: ${body}` : ''}`);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-29 00:47:29 +03:00
|
|
|
const resolveScheduledCommand = async ({ client, projectPath, task }) => {
|
2026-04-16 15:55:08 +03:00
|
|
|
const parsed = parseScheduledCommandPrompt(task?.execution?.prompt);
|
|
|
|
|
if (!parsed) {
|
2026-07-29 00:47:29 +03:00
|
|
|
return null;
|
2026-04-16 15:55:08 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let commands = [];
|
|
|
|
|
try {
|
|
|
|
|
const response = await client.command.list({ directory: projectPath });
|
|
|
|
|
commands = Array.isArray(response?.data) ? response.data : [];
|
|
|
|
|
} catch {
|
2026-07-29 00:47:29 +03:00
|
|
|
return null;
|
2026-04-16 15:55:08 +03:00
|
|
|
}
|
|
|
|
|
|
2026-07-29 00:47:29 +03:00
|
|
|
const command = commands.find((candidate) => candidate?.name === parsed.command);
|
|
|
|
|
return command ? { ...parsed, template: command.template } : null;
|
|
|
|
|
};
|
2026-04-16 15:55:08 +03:00
|
|
|
|
2026-07-29 00:47:29 +03:00
|
|
|
const runScheduledCommand = async ({ client, projectPath, sessionID, task, command }) => {
|
2026-04-16 15:55:08 +03:00
|
|
|
await client.session.command({
|
|
|
|
|
sessionID,
|
|
|
|
|
directory: projectPath,
|
2026-07-29 00:47:29 +03:00
|
|
|
command: command.command,
|
|
|
|
|
arguments: command.arguments,
|
2026-04-16 15:55:08 +03:00
|
|
|
...(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 {
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-20 23:07:01 +03:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 00:47:29 +03:00
|
|
|
const scheduledCommand = await resolveScheduledCommand({ client, projectPath, task });
|
|
|
|
|
|
2026-07-12 01:23:22 +03:00
|
|
|
if (task.execution.goalEnabled) {
|
2026-07-29 00:47:29 +03:00
|
|
|
const commandObjective = scheduledCommand
|
|
|
|
|
? expandCommandGoalObjective(scheduledCommand.template, scheduledCommand.arguments)
|
|
|
|
|
: null;
|
2026-07-24 21:54:28 +03:00
|
|
|
await createSessionGoal({
|
|
|
|
|
baseUrl,
|
|
|
|
|
authHeaders,
|
|
|
|
|
sessionID,
|
|
|
|
|
directory: projectPath,
|
2026-07-29 00:47:29 +03:00
|
|
|
objective: commandObjective ?? expandSnippets(task.execution.prompt, projectPath),
|
2026-07-24 21:54:28 +03:00
|
|
|
tokenBudget: task.execution.goalTokenBudget,
|
|
|
|
|
providerID: task.execution.providerID,
|
|
|
|
|
modelID: task.execution.modelID,
|
|
|
|
|
onWarning: (message, error) => console.warn(`[scheduled-tasks] ${message}:`, error?.message || error),
|
|
|
|
|
});
|
2026-07-12 01:23:22 +03:00
|
|
|
}
|
|
|
|
|
|
2026-07-29 00:47:29 +03:00
|
|
|
if (scheduledCommand) {
|
|
|
|
|
await runScheduledCommand({ client, projectPath, sessionID, task, command: scheduledCommand });
|
|
|
|
|
} else {
|
2026-04-16 15:55:08 +03:00
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-30 13:54:58 +03:00
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-16 15:55:08 +03:00
|
|
|
return {
|
|
|
|
|
start,
|
|
|
|
|
stop,
|
|
|
|
|
syncAllProjects,
|
|
|
|
|
syncProject,
|
|
|
|
|
runNow,
|
2026-04-30 13:54:58 +03:00
|
|
|
getStatus,
|
2026-04-16 15:55:08 +03:00
|
|
|
};
|
|
|
|
|
};
|