The panel stored notes, todos and plans inside one shared JSON file that six unrelated domains also wrote to, synchronised itself through window CustomEvents, and could only read plans. It is now Project knowledge: server-owned storage with explicit routes, a store with rollback, a section sidebar, plans that open and edit in place, and search across all of it. Notes and plans the user pins travel with every message sent in that project. Pinning is project state, not an attachment to one message, so it holds until unpinned and the work status panel names what is riding along and can detach it. Agent memory is added alongside, in two scopes: what is true about the user, and what is true about this codebase. The split is not cosmetic — a wrong project fact costs one project and is noticed, while a wrong global fact quietly shapes every session everywhere and the user has no code to check it against. It stays separate from notes so an agent mistake cannot land in what the user wrote. Sessions receive an index of titles only; bodies are read on demand, because an index carrying full text grows until it crowds out the conversation. Deciding what a session must be told, and whether it has been told, now lives on the server. The client owned it before, which meant sessions started without a UI — scheduled tasks, sessions the agent dispatches — received nothing at all, and a tab's record of what it had sent outlived the conversation: after compaction the agent no longer held the block while the tab went on believing it did. What was delivered is recorded in the session's own metadata, and compaction restores it through the runtime that already restores pinned messages, in the same turn. Agent memory ships dark behind OPENCHAMBER_MEMORY_ENABLE: unset, there is no tool, no routes, no session index, no settings row and no panel tab. Absent rather than switched off, so nothing invites turning on a feature that has not been announced. Pinned notes and plans are unaffected and ship as normal.
1123 lines
35 KiB
JavaScript
1123 lines
35 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';
|
|
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,
|
|
sessionKnowledgeRuntime = null,
|
|
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', nextRunAt);
|
|
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, scheduledFor) => {
|
|
const taskKey = buildTaskKey(projectID, taskID);
|
|
if (queuedTaskKeys.has(taskKey) || runningTaskKeys.has(taskKey)) {
|
|
return;
|
|
}
|
|
queuedTaskKeys.add(taskKey);
|
|
queue.push({
|
|
projectID,
|
|
taskID,
|
|
reason,
|
|
...(Number.isFinite(scheduledFor) ? { scheduledFor } : {}),
|
|
});
|
|
};
|
|
|
|
const canRunTask = (projectID) => {
|
|
if (runningGlobalCount >= maxGlobalConcurrency) {
|
|
return false;
|
|
}
|
|
const projectRunning = runningCountByProject.get(projectID) || 0;
|
|
return projectRunning < maxProjectConcurrency;
|
|
};
|
|
|
|
const buildPromptAsyncPayload = (task, projectPath, knowledgeText = '') => ({
|
|
model: {
|
|
providerID: task.execution.providerID,
|
|
modelID: task.execution.modelID,
|
|
},
|
|
...(task.execution.agent ? { agent: task.execution.agent } : {}),
|
|
...(task.execution.variant ? { variant: task.execution.variant } : {}),
|
|
parts: [
|
|
// Standing project context first, so the prompt reads against it. A
|
|
// scheduled run has no UI to attach this, which is why it is asked for
|
|
// here rather than assembled by whoever is sending.
|
|
...(knowledgeText ? [{ type: 'text', text: knowledgeText, synthetic: true }] : []),
|
|
{
|
|
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 }) => {
|
|
// Never allowed to fail the run: a task that executes without its
|
|
// background is a lesser loss than a task that does not execute.
|
|
const knowledge = sessionKnowledgeRuntime
|
|
? await sessionKnowledgeRuntime.resolvePendingForSession(sessionID, projectPath)
|
|
.catch(() => ({ text: '', signature: '' }))
|
|
: { text: '', signature: '' };
|
|
|
|
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, knowledge.text)),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const body = await response.text().catch(() => '');
|
|
throw new Error(`prompt_async failed (${response.status})${body ? `: ${body}` : ''}`);
|
|
}
|
|
|
|
// Recorded only after the prompt is accepted, so a failed dispatch carries
|
|
// the context again on the next run.
|
|
if (knowledge.text && sessionKnowledgeRuntime) {
|
|
await sessionKnowledgeRuntime.recordDelivered(sessionID, projectPath, knowledge.signature)
|
|
.catch(() => undefined);
|
|
}
|
|
};
|
|
|
|
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 releaseRunningSlot = (projectID, taskKey) => {
|
|
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);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Arm a timer only for a future occurrence. Scheduling a past nextRunAt
|
|
* (delay 0 + jitter) re-enters the claim path immediately and can spin —
|
|
* especially for once tasks where the claim cannot advance nextRunAt.
|
|
*/
|
|
const scheduleFutureRun = (projectID, taskID, nextRunAt, fromMs = Date.now()) => {
|
|
if (!Number.isFinite(nextRunAt)) {
|
|
return false;
|
|
}
|
|
const base = Number.isFinite(fromMs) ? fromMs : Date.now();
|
|
if (nextRunAt <= base) {
|
|
return false;
|
|
}
|
|
scheduleTask(projectID, taskID, nextRunAt);
|
|
return true;
|
|
};
|
|
|
|
const rearmFromTaskOrCompute = (projectID, taskID, fallbackTask, fromMs) => {
|
|
const latest = (tasksByProject.get(projectID)?.get(taskID)) || fallbackTask;
|
|
if (!latest?.enabled) {
|
|
return;
|
|
}
|
|
const base = Number.isFinite(fromMs) ? fromMs : Date.now();
|
|
const persistedNext = latest.state?.nextRunAt;
|
|
// Prefer a still-future persisted slot; never re-arm a past occurrence
|
|
// (that created silent once-task loser loops and claim-failed retry spam).
|
|
if (scheduleFutureRun(projectID, taskID, persistedNext, base)) {
|
|
return;
|
|
}
|
|
const computedNext = computeNextRunAt(latest, base);
|
|
scheduleFutureRun(projectID, taskID, computedNext, base);
|
|
};
|
|
|
|
const runTask = async (projectID, taskID, reason, scheduledFor) => {
|
|
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);
|
|
|
|
// Every path that holds the running slot must exit through this finally so
|
|
// lock timeouts / fs errors on claim, manual-start, or completion writes
|
|
// cannot permanently stuck-run the task in this process.
|
|
try {
|
|
const runStartedAt = Date.now();
|
|
|
|
// Scheduled dispatches must claim the occurrence in shared project config
|
|
// before creating a session. Two server instances (e.g. CLI serve + desktop)
|
|
// each arm their own timer; without this claim both would run (#2710).
|
|
if (reason === 'scheduled') {
|
|
if (!Number.isFinite(scheduledFor)) {
|
|
return { ok: false, skipped: true, reason: 'missing-scheduled-for' };
|
|
}
|
|
|
|
const nextAfterClaim = computeNextRunAt(task, Math.max(runStartedAt, scheduledFor + 1));
|
|
const claimPatch = {
|
|
lastScheduledFor: Math.round(scheduledFor),
|
|
lastRunAt: runStartedAt,
|
|
lastStatus: 'running',
|
|
lastError: undefined,
|
|
updatedAt: runStartedAt,
|
|
// Always set nextRunAt so a past once-slot is cleared when there is
|
|
// no following occurrence (omitting the key would leave the past value).
|
|
nextRunAt: Number.isFinite(nextAfterClaim) ? nextAfterClaim : undefined,
|
|
};
|
|
|
|
// Duplicate protection is solely lastScheduledFor within slack of this
|
|
// occurrence. Do not reject on advanced disk nextRunAt: lastScheduledFor
|
|
// persists across days, so a second-instance sync inside TASK_DUE_SLACK_MS
|
|
// would otherwise suppress every armed occurrence after the first.
|
|
const canClaimOccurrence = (candidate) => {
|
|
if (!candidate?.enabled) {
|
|
return false;
|
|
}
|
|
const lastScheduledFor = candidate.state?.lastScheduledFor;
|
|
if (
|
|
Number.isFinite(lastScheduledFor)
|
|
&& Math.abs(lastScheduledFor - scheduledFor) <= TASK_DUE_SLACK_MS
|
|
) {
|
|
return false;
|
|
}
|
|
return true;
|
|
};
|
|
|
|
let claimResult;
|
|
try {
|
|
if (typeof projectConfigRuntime.updateScheduledTaskStateIf === 'function') {
|
|
claimResult = await projectConfigRuntime.updateScheduledTaskStateIf(
|
|
projectID,
|
|
taskID,
|
|
canClaimOccurrence,
|
|
claimPatch,
|
|
);
|
|
} else {
|
|
// Fallback for older test doubles: unconditional update (single-instance only).
|
|
claimResult = await projectConfigRuntime.updateScheduledTaskState(projectID, taskID, claimPatch);
|
|
claimResult = { ...claimResult, updated: Boolean(claimResult?.task) };
|
|
}
|
|
} catch (claimError) {
|
|
const message = safeErrorMessage(claimError);
|
|
logger.warn?.('[ScheduledTasks] occurrence claim failed', {
|
|
projectID,
|
|
taskID,
|
|
error: message,
|
|
});
|
|
rearmFromTaskOrCompute(projectID, taskID, task, Math.max(runStartedAt, scheduledFor + 1));
|
|
|
|
// Best-effort record so once tasks are not left enabled-but-inert with
|
|
// no UI signal. Do not clobber a winner that claimed this occurrence.
|
|
const claimFailurePatch = {
|
|
lastStatus: 'error',
|
|
lastError: `Scheduled claim failed: ${message}`,
|
|
updatedAt: Date.now(),
|
|
};
|
|
try {
|
|
if (typeof projectConfigRuntime.updateScheduledTaskStateIf === 'function') {
|
|
const recorded = await projectConfigRuntime.updateScheduledTaskStateIf(
|
|
projectID,
|
|
taskID,
|
|
(candidate) => {
|
|
const lastScheduledFor = candidate.state?.lastScheduledFor;
|
|
if (
|
|
Number.isFinite(lastScheduledFor)
|
|
&& Math.abs(lastScheduledFor - scheduledFor) <= TASK_DUE_SLACK_MS
|
|
) {
|
|
return false;
|
|
}
|
|
return true;
|
|
},
|
|
claimFailurePatch,
|
|
);
|
|
if (recorded.task) {
|
|
updateInMemoryTask(projectID, recorded.task);
|
|
}
|
|
} else {
|
|
const recorded = await projectConfigRuntime.updateScheduledTaskState(
|
|
projectID,
|
|
taskID,
|
|
claimFailurePatch,
|
|
);
|
|
if (recorded.task) {
|
|
updateInMemoryTask(projectID, recorded.task);
|
|
}
|
|
}
|
|
} catch {
|
|
updateInMemoryTask(projectID, {
|
|
...task,
|
|
state: {
|
|
...(task.state || {}),
|
|
...claimFailurePatch,
|
|
},
|
|
});
|
|
}
|
|
|
|
return { ok: false, skipped: true, reason: 'claim-failed', error: message };
|
|
}
|
|
|
|
if (!claimResult?.updated) {
|
|
if (claimResult?.task) {
|
|
updateInMemoryTask(projectID, claimResult.task);
|
|
// Loser must not schedule a past nextRunAt (once-task spin).
|
|
rearmFromTaskOrCompute(
|
|
projectID,
|
|
taskID,
|
|
claimResult.task,
|
|
Math.max(Date.now(), scheduledFor + 1),
|
|
);
|
|
}
|
|
return { ok: false, skipped: true, reason: 'occurrence-claimed' };
|
|
}
|
|
|
|
if (claimResult.task) {
|
|
updateInMemoryTask(projectID, claimResult.task);
|
|
}
|
|
} else {
|
|
try {
|
|
const startResult = await projectConfigRuntime.updateScheduledTaskState(projectID, taskID, {
|
|
lastRunAt: runStartedAt,
|
|
lastStatus: 'running',
|
|
lastError: undefined,
|
|
updatedAt: runStartedAt,
|
|
});
|
|
if (startResult.task) {
|
|
updateInMemoryTask(projectID, startResult.task);
|
|
}
|
|
} catch (startError) {
|
|
const message = safeErrorMessage(startError);
|
|
logger.warn?.('[ScheduledTasks] manual start state write failed', {
|
|
projectID,
|
|
taskID,
|
|
error: message,
|
|
});
|
|
return { ok: false, error: message, reason: 'start-state-failed' };
|
|
}
|
|
}
|
|
|
|
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,
|
|
};
|
|
|
|
let stateResult = { task: null };
|
|
try {
|
|
stateResult = await projectConfigRuntime.updateScheduledTaskState(projectID, taskID, statePatch);
|
|
if (stateResult.task) {
|
|
updateInMemoryTask(projectID, stateResult.task);
|
|
if (stateResult.task.enabled) {
|
|
scheduleFutureRun(
|
|
projectID,
|
|
taskID,
|
|
stateResult.task.state?.nextRunAt,
|
|
finishedAt,
|
|
);
|
|
}
|
|
}
|
|
} catch (persistError) {
|
|
const message = safeErrorMessage(persistError);
|
|
logger.warn?.('[ScheduledTasks] run completion state write failed', {
|
|
projectID,
|
|
taskID,
|
|
reason,
|
|
error: message,
|
|
});
|
|
|
|
// Keep in-memory status terminal so this process does not advertise
|
|
// a stuck "running" task after the session already finished.
|
|
const recoveredTask = {
|
|
...latestTask,
|
|
state: {
|
|
...(latestTask.state || {}),
|
|
lastStatus: status,
|
|
lastDurationMs: durationMs,
|
|
lastError: status === 'error' ? errorMessage : undefined,
|
|
lastSessionId: status === 'success' ? sessionID : undefined,
|
|
nextRunAt: Number.isFinite(nextRunAt) ? nextRunAt : undefined,
|
|
updatedAt: finishedAt,
|
|
},
|
|
};
|
|
updateInMemoryTask(projectID, recoveredTask);
|
|
|
|
// Best-effort single retry so persisted lastStatus does not stay 'running'.
|
|
try {
|
|
const retry = await projectConfigRuntime.updateScheduledTaskState(projectID, taskID, statePatch);
|
|
if (retry.task) {
|
|
updateInMemoryTask(projectID, retry.task);
|
|
stateResult = retry;
|
|
if (retry.task.enabled) {
|
|
scheduleFutureRun(projectID, taskID, retry.task.state?.nextRunAt, finishedAt);
|
|
}
|
|
}
|
|
} catch (retryError) {
|
|
logger.warn?.('[ScheduledTasks] run completion state retry failed', {
|
|
projectID,
|
|
taskID,
|
|
reason,
|
|
error: safeErrorMessage(retryError),
|
|
});
|
|
stateResult = { task: recoveredTask };
|
|
rearmFromTaskOrCompute(projectID, taskID, recoveredTask, finishedAt);
|
|
}
|
|
|
|
// The session already ran — surface persist failure without treating a
|
|
// successful dispatch as a hard run failure (manual runNow would 500).
|
|
return {
|
|
ok: status === 'success',
|
|
status,
|
|
sessionID,
|
|
task: stateResult.task || recoveredTask,
|
|
error: status === 'error' ? errorMessage : undefined,
|
|
persistError: message,
|
|
reason: 'completion-state-failed',
|
|
};
|
|
}
|
|
|
|
try {
|
|
emitTaskRunEvent?.({
|
|
projectID,
|
|
taskID,
|
|
ranAt: finishedAt,
|
|
status,
|
|
...(sessionID ? { sessionID } : {}),
|
|
});
|
|
} catch {
|
|
}
|
|
|
|
return {
|
|
ok: status === 'success',
|
|
status,
|
|
sessionID,
|
|
task: stateResult.task || null,
|
|
error: errorMessage,
|
|
};
|
|
} finally {
|
|
releaseRunningSlot(projectID, taskKey);
|
|
}
|
|
};
|
|
|
|
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, item.scheduledFor)
|
|
.catch((error) => {
|
|
logger.warn?.('[ScheduledTasks] queued run rejected', {
|
|
projectID: item.projectID,
|
|
taskID: item.taskID,
|
|
reason: item.reason,
|
|
error: safeErrorMessage(error),
|
|
});
|
|
})
|
|
.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,
|
|
};
|
|
};
|