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-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 (),
};
};
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 ,
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 );
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 ;
};
2026-07-12 01:23:22 +03:00
// Same instruction the composer attaches on an armed goal send: the agent
// must know goal mode is on from turn one, and each turn has to end with a
// factual report for the independent audit.
const buildGoalIntroText = ( tokenBudget ) => {
const budgetLine = tokenBudget
? ` A token budget of ${ tokenBudget } tokens applies to this goal.`
: '' ;
return '<system-reminder>\n'
+ 'Goal mode is active for this session. The user message above defines the goal objective. '
+ 'Work toward it across turns; whenever you stop before the objective is verifiably complete, the system will automatically prompt you to continue. '
+ 'Progress is evaluated independently after each turn, so end every turn with a clear, factual statement of what is done, what was verified, and what remains.'
+ budgetLine
+ '\n</system-reminder>' ;
};
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
],
});
2026-07-12 01:23:22 +03:00
// Scheduled goal runs: stamp the goal onto the fresh session's metadata
// before the prompt goes out; the session-goal runtime picks the loop up
// from session events like any other goal.
const createTaskGoal = async ({ baseUrl , authHeaders , sessionID , projectPath , task }) => {
const now = Date . now ();
2026-07-12 02:49:56 +03:00
// File-backed objective keyed by session id: metadata stays light, the
// full expanded prompt lives under the OpenChamber data dir. If the file
// write fails, fall back to an inline (clamped) objective.
2026-07-12 16:34:16 +03:00
// Oversized prompts are distilled into audit criteria by the small model
// (the working agent gets the full prompt in chat anyway); on distill
// failure a head+tail excerpt keeps intent and acceptance criteria.
let objectiveText = expandSnippets ( task . execution . prompt , projectPath );
if ( objectiveText . length > 5000 ) {
let distilled = null ;
try {
const { generateSmallModelText } = await import ( '../small-model/index.js' );
const generated = await generateSmallModelText ({
restrictToPreferredProvider : true ,
prompt : objectiveText ,
system : [
'You distill a large task description into the COMPLETION CRITERIA a progress auditor will judge against.' ,
'Return ONLY the criteria text — no preamble, no headers, no markdown fences.' ,
'Capture: the end goals, what must exist and work when the task is fully done, and how each major part is verified. Omit implementation steps.' ,
'Preserve verbatim any file paths, commands, and identifiers that define the task.' ,
'Stay under 4000 characters.' ,
'Write in the same language as the task text.' ,
]. join ( '\n' ),
directory : projectPath ,
preferredProviderID : task . execution . providerID ,
preferredModelID : task . execution . modelID ,
});
distilled = typeof generated ? . text === 'string' ? generated . text . trim () : null ;
} catch ( error ) {
console . warn ( '[scheduled-tasks] goal objective distillation failed:' , error ? . message || error );
}
if ( distilled ) {
objectiveText = distilled ;
} else {
const marker = '\n\n[… objective trimmed for the auditor — the full prompt was delivered in the chat message …]\n\n' ;
const half = Math . max ( 0 , Math . floor (( 5000 - marker . length ) / 2 ));
objectiveText = ` ${ objectiveText . slice ( 0 , half ) }${ marker }${ objectiveText . slice ( - half ) } ` ;
}
}
2026-07-12 02:49:56 +03:00
let objectiveFile = false ;
try {
const { writeObjective } = await import ( '../session-goal/objectives.js' );
await writeObjective ( sessionID , objectiveText );
objectiveFile = true ;
} catch ( error ) {
console . warn ( '[scheduled-tasks] goal objective file write failed, falling back to inline:' , error ? . message || error );
}
2026-07-12 01:23:22 +03:00
const goal = {
id : ` ${ now . toString ( 36 ) }${ Math . random (). toString ( 36 ). slice ( 2 , 8 ) } ` ,
2026-07-12 02:49:56 +03:00
objective : objectiveFile ? '' : objectiveText . slice ( 0 , 5000 ),
objectiveFile ,
2026-07-12 01:23:22 +03:00
status : 'active' ,
tokenBudget : task . execution . goalTokenBudget || null ,
tokensUsed : 0 ,
turnsUsed : 0 ,
blockedStreak : 0 ,
note : '' ,
statusReason : '' ,
lastAccountedMessageID : '' ,
createdAt : now ,
updatedAt : now ,
};
const url = new URL ( ` ${ baseUrl } /session/ ${ encodeURIComponent ( sessionID ) } ` );
url . searchParams . set ( 'directory' , projectPath );
const response = await fetch ( url . toString (), {
method : 'PATCH' ,
headers : {
... authHeaders ,
'content-type' : 'application/json' ,
accept : 'application/json' ,
},
body : JSON . stringify ({ metadata : { openchamber : { goal } } }),
});
if ( ! response . ok ) {
throw new Error ( `goal metadata patch failed ( ${ response . status } )` );
}
};
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 } ` : '' } ` );
}
};
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 {
}
2026-07-12 01:23:22 +03:00
if ( task . execution . goalEnabled ) {
await createTaskGoal ({ baseUrl , authHeaders , sessionID , projectPath , task });
}
2026-04-16 15:55:08 +03:00
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 ;
};
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
};
};