Files
openchamber/packages/web/server/lib/scheduled-tasks/runtime.js
T

843 lines
24 KiB
JavaScript
Raw Normal View History

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;
};
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,
};
};
return {
start,
stop,
syncAllProjects,
syncProject,
runNow,
2026-04-30 13:54:58 +03:00
getStatus,
};
};