Add a shared OpenChamber control service with two thin adapters — a native `openchamber` tool injected into managed OpenCode, and new CLI commands — so users can manage parallel sessions, worktrees, and scheduled tasks conversationally through agents or from the terminal. Control plane: - New openchamber-control service owning a fixed action contract: projects.list, models.list, session list/create/send/fork/status/messages, and schedule list/create/run/delete/toggle. Session and worktree deletion and project registration are deliberately not exposed. - New openchamber-sessions module owning create/worktree/prompt orchestration, Goal Mode dispatch, wait semantics (initial idle never counts as completion; timeout and cancellation are failures), and explicit partial-failure results. - Scheduled-task logic extracted into a service shared by routes, CLI, and the agent tool. Agent tool: - Managed OpenCode gets a materialized plugin registering one typed tool with a loopback-only callback, per-child ephemeral bearer (timing-safe, never persisted or logged), and abort propagation into the service. - The ~1.5k-token schema applies progressive disclosure: short descriptions, server-side validation returning actionable usage errors, and intent guardrails — created sessions/tasks are user-facing work (not age self-delegation); worktree/goal/agent/variant/wait are omit-by-default; dispatches produce no completion notification, and later result r to session.messages, which now returns the authoritative sessionStatus. - session.create without a user-named model picks from favorites/re send/fork omit the selection and the service reuses the target session's last user-message model, agent, and variant before falling back t - An "Agent control tool" setting (default on, Save + Reload to apply) disables plugin injection entirely. CLI: - New `openchamber session`, `schedule`, `projects`, and `models` commands with automatic instance targeting, --wait/--timeout/--last-assist worktree flags, and Goal Mode, preserving interactive, non-TTY, --quiet, and --json contracts. The control HTTP timeout derives from the w instead of the 4-second default. UI: - New built-in "Schedule a Task" starter (/schedule-task) running a dialogue that defines a task and offers to create it via the tool after explicit confirmation; Craft a Goal and Feature Planning gain the handoff offer, and guided starters reserve the question tool for concrete option choices. Localized in all 10 locales, migrated into custom starter lists, hidden on VS Code. - Sidebar shows CLI/agent-created sessions live via the control eve - openchamber tool calls render with per-action titles and metadata.
805 lines
22 KiB
JavaScript
805 lines
22 KiB
JavaScript
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';
|
|
|
|
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 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 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 runScheduledCommandIfApplicable = async ({ client, projectPath, sessionID, task }) => {
|
|
const parsed = parseScheduledCommandPrompt(task?.execution?.prompt);
|
|
if (!parsed) {
|
|
return false;
|
|
}
|
|
|
|
let commands = [];
|
|
try {
|
|
const response = await client.command.list({ directory: projectPath });
|
|
commands = Array.isArray(response?.data) ? response.data : [];
|
|
} catch {
|
|
return false;
|
|
}
|
|
|
|
const hasMatchingCommand = commands.some((command) => command?.name === parsed.command);
|
|
if (!hasMatchingCommand) {
|
|
return false;
|
|
}
|
|
|
|
await client.session.command({
|
|
sessionID,
|
|
directory: projectPath,
|
|
command: parsed.command,
|
|
arguments: parsed.arguments,
|
|
...(task.execution.agent ? { agent: task.execution.agent } : {}),
|
|
model: `${task.execution.providerID}/${task.execution.modelID}`,
|
|
...(task.execution.variant ? { variant: task.execution.variant } : {}),
|
|
});
|
|
|
|
return true;
|
|
};
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
if (task.execution.goalEnabled) {
|
|
await createSessionGoal({
|
|
baseUrl,
|
|
authHeaders,
|
|
sessionID,
|
|
directory: projectPath,
|
|
objective: 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),
|
|
});
|
|
}
|
|
|
|
const executedAsCommand = await runScheduledCommandIfApplicable({
|
|
client,
|
|
projectPath,
|
|
sessionID,
|
|
task,
|
|
});
|
|
if (!executedAsCommand) {
|
|
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,
|
|
};
|
|
};
|