This document describes how the task scheduler module should create, persist, and evaluate schedules.
Module Contract
The task scheduler is a separated Friday module. It owns timing, schedule
persistence, due-run processing, retry policy, concurrency policy, audit events,
and the LLM-facing cron tool.
Module surfaces:
- Service/facade:
CronServicein the main process. - LLM tool:
cron. - Renderer IPC:
cron:*channels. - Storage: root
taskSchedulersettings.
Dependencies:
StoreServiceandCronScheduleStorefor persisted schedule state.- Clock/timer utilities and cron-expression calculation.
- Task execution modules for the work that runs after a schedule fires.
AgentServiceonly for Friday cron jobs that intentionally run an agent turn.- Event and audit modules for lifecycle reporting.
The scheduler does not own provider or model selection for model-backed work. Provider-backed execution belongs to the module that performs the work.
Purpose
The task scheduler owns scheduled work in the main process. It supports three related scheduling paths:
- Managed schedules through
CronSchedulerService. - Friday LLM tool schedules through
FridayCronScheduler. - Legacy
node-cronjobs throughCronService.schedule().
Managed schedules are the richer schedule model. They include permissions, auditing, persistence, missed-run handling, concurrency policy, retries, and execution records.
Friday tool schedules are the user-facing agent cron jobs handled by the cron
tool. They store a compact job definition and run agent turns or system events.
The legacy node-cron path schedules a persisted expression and handler
directly. It remains available for simple cron tasks.
Managed Schedule Creation
Managed schedules are created through CronService.createSchedule(request, actor), which delegates to CronSchedulerService.createSchedule().
Renderer callers use the cron:createSchedule IPC channel. The IPC handler
validates the minimum request shape, defaults the source to ui when needed,
builds a UI actor, and calls cron.createSchedule().
Agent callers use AgentCronService.createScheduleFromAgent(). That helper
fills the agent source fields:
source: 'agent'sourceId: agentContext.agentIdcreatedBy: agentContext.agentIdownerUserId,sessionId,timezone, andvisibilityfrom the agent context or request
The managed creation flow is:
- Authorize the actor for
createSchedule. - Validate frequency limits through the access policy.
- Validate the schedule shape with
validateScheduleShape(). - Reject secret-looking keys inside
taskInput. - Build a full
CronSchedulerecord from the create request. - Compute the first
nextRunAtwithCronNextRunCalculator. - Add a
schedule.createdaudit entry. - Persist the schedule with
CronScheduleStore.createSchedule(). - Append and emit a
schedule.createdevent.
Managed Schedule Request
The create request should use this shape:
Required fields:
{
name: string;
type: CronScheduleType;
source: CronScheduleSource;
createdBy: string;
timezone: string;
taskType: string;
taskInput: CronJsonValue;
}
Schedule-specific required fields:
type: 'cron'requirescronExpression.type: 'interval',type: 'fixedRate', andtype: 'fixedDelay'requireintervalMs.type: 'oneTime'requiresrunAt.type: 'calendar'andtype: 'manual'do not currently produce automatic next runs.
Common optional fields:
- Ownership and visibility:
ownerUserId,sessionId,visibility. - Bounds:
startAt,endAt,maxRuns. - Missed-run behavior:
missedRunPolicy,maxCatchUpRuns,catchUpWindowMs. - Concurrency behavior:
concurrencyPolicy. - Retry behavior:
retryPolicy. - Task metadata:
taskPriority,taskTags,taskMetadata. - Security:
requiredPermissions,requiresConfirmation,confirmationPolicy. - State:
enabled,metadata.
Managed Schedule Defaults
CronSchedulerService.createSchedule() fills these defaults:
id: generated UUID.status:activeunlessenabledis explicitlyfalse, thendisabled.ownerUserId: request value or actor user id.sessionId: request value or actor session id.visibility:user.timezone: request timezone, actor timezone, or scheduler default timezone.runCount:0.missedRunPolicy:skip.maxCatchUpRuns: scheduler run policy default.catchUpWindowMs: scheduler run policy default.concurrencyPolicy:skipIfRunning.retryPolicy: scheduler default retry policy merged with the request retry policy.taskPriority:normal.taskTags: empty array.taskMetadata: empty object.requiredPermissions: empty array.requiresConfirmation:false.enabled:true.metadata: empty object.createdAtandupdatedAt: current ISO timestamp.
The cron expression is normalized by trimming it and collapsing repeated whitespace.
Next Run Calculation
CronNextRunCalculator.getNextRun(schedule, from) calculates the next run
based on the schedule type.
For cron schedules:
- The expression must use 5 fields: minute, hour, day of month, month, day of week.
- Fields support
*, comma lists, ranges, and step values. - Time matching is evaluated in the schedule timezone through
Intl.DateTimeFormat. - The calculator starts at the next full minute after
from. - It scans forward up to 366 days.
- If both day-of-month and day-of-week are restricted, either may match.
- If only one of day-of-month or day-of-week is restricted, both normal cron fields must match.
For interval schedules:
intervaluses the last run/evaluation/start/create time as the base.fixedRateuses the original start/create anchor as the base.fixedDelayschedules after the last successful run, last run, start, or create time.
For one-time schedules:
runAtis returned only if it is in the future, the schedule has not run, and the schedule is not completed.
For all managed schedule types:
- Disabled or deleted schedules return no next run.
- Schedules at
maxRunsreturn no next run. startAt,endAt, andmaxRunsbounds are enforced.
Runtime Processing
CronSchedulerService.start() recovers schedules on startup, then checks due
schedules every pollIntervalMs milliseconds. The default poll interval is 30
seconds.
Due processing works like this:
- Load active schedules whose
nextRunAtis due. - Limit the batch to
runPolicy.maxRunsPerTurn. - Acquire a per-schedule lock.
- Re-read and validate that the schedule can still run.
- Emit
schedule.due. - Build an idempotency key from
scheduleIdandscheduledRunAt. - Ignore duplicate executions or already-created tasks.
- Apply the concurrency policy.
- Create a scheduled task through the runner, with retries if configured.
- Increment
runCount, updatelastRunAt, recomputenextRunAt, and mark completed schedules. - Record an execution and emit
schedule.triggered.
The default runner is InMemoryCronScheduleRunner. It creates a
CronScheduledTask with:
source: 'cron'sourceId: schedule idtype: scheduletaskTypeinput: redacted scheduletaskInput- metadata containing the scheduled time, actual trigger time, run number, missed-run flag, runner id, and idempotency key
Module-Backed Scheduled Work
The task scheduler owns timing, persistence, and due-run processing. It does not own provider or model selection for model-backed work.
When a schedule triggers module-backed work, the schedule stores the task type and sanitized task input only. The task handler or target main-process module resolves its own provider, model, endpoint, and runtime dependencies when it executes.
Persisted module settings are storage details. Schedules should depend on task types and sanitized inputs, not on other module settings keys.
Recommended module task types:
text-to-speech.run: calls the text-to-speech module.image.create: calls the image module.video.create: calls the video module.sound.create: calls the sound module.ocr.run: calls the OCR module or the configured OCR endpoint.embedding.index: calls the embedding module when that module exists.
Cron payloads must not store API keys, base URLs, webhook secrets, or raw
provider records. If a future scheduled task supports a provider/model
override, the payload may contain only ids; credentials and full provider
configuration must still be resolved from StoreService.
Example media schedule:
await cron.createSchedule({
name: 'Create weekly product image',
type: 'cron',
source: 'agent',
sourceId: 'agent',
createdBy: 'agent',
visibility: 'user',
timezone: 'Europe/Rome',
cronExpression: '0 8 * * 1',
missedRunPolicy: 'skip',
concurrencyPolicy: 'skipIfRunning',
taskType: 'image.create',
taskInput: {
prompt: 'Create a square product image for the weekly announcement.',
aspectRatio: '1:1',
count: 1,
},
});
When this schedule fires, the task scheduler creates or dispatches an
image.create task. The image task calls the image module, and the image module
reads its saved settings to choose the provider and model.
Missed Runs
Startup recovery handles schedules whose persisted nextRunAt is already in
the past.
Policies:
skip: compute the next future run and emitschedule.skipped.runOnce: trigger one missed run.catchUp: trigger missed runs within the catch-up window, limited bymaxCatchUpRunsandmaxRunsPerTurn.fail: mark the schedule failed.askUser: currently triggers the missed run.
Concurrency Policies
When a schedule is due and a previous task is still running:
allowOverlap: create another task.skipIfRunning: record a skipped execution and move to the next run.queueIfRunning: currently proceeds to create the next task.cancelPrevious: cancel running tasks, then create the new task.replacePrevious: cancel running tasks, then create the new task.
Friday Tool Schedule Creation
Friday cron jobs are created through CronService.fridayAction(request, actor, context), which delegates to FridayCronScheduler.handleToolAction().
The normal add flow is:
- Normalize the raw tool request with
normalizeFridayCronToolRequest(). - Convert the add payload into a
FridayCronAddRequest. - Validate the actor can add jobs.
- Load the Friday cron store snapshot.
- Create a
FridayCronJobDefinition. - Validate the job with
assertValidFridayJob(). - Create default job state.
- Compute
state.nextRunAtMswhen the job is enabled. - Save the snapshot.
- Arm the next timer.
Friday schedules support three schedule shapes:
type FridayCronSchedule =
| { kind: 'at'; at: string }
| { kind: 'every'; everyMs: number; anchorMs?: number }
| { kind: 'cron'; expr: string; tz?: string; staggerMs?: number };
Normalization accepts compact forms:
atoratMscreates{ kind: 'at' }.everyMscreates{ kind: 'every' }.exprorcroncreates{ kind: 'cron' }.tzsets the cron timezone.exact: truedisables stagger by settingstaggerMsto0.
Friday next-run calculation works like this:
at: returns the timestamp until it has run once.every: advances fromanchorMsor job creation time byeveryMs, bounded by the scheduler minimum refire gap.cron: reusesCronNextRunCalculatorby wrapping the Friday job as a temporary managed cron schedule, then addsstaggerMswhen present.
Legacy Node-Cron Jobs
CronService.schedule(id, expression, data, handler, options) uses the
node-cron package directly.
Creation flow:
- Reject duplicate job ids.
- Validate the cron expression with
node-cron. - Build a persisted
CronTaskrecord. - If automatic cron execution is disabled, persist the task without arming it.
- Otherwise call
cron.schedule(expression, handler, { timezone }). - Store the registered job in memory.
- Persist the task through
StoreService. - Optionally run the handler immediately when
runOnStartis set.
Persisted legacy tasks are restored with CronService.restore(dispatcher).
Restore validates each expression, recreates the node-cron schedule, and
dispatches through the supplied handler.
Disabling Automatic Execution
Automatic execution is disabled when either condition is true:
SKIP_CRON=1CRON_ENABLED=false
When disabled, managed scheduler execution is not started and Friday cron timers are not armed. Legacy jobs are still persisted, but their timers are not scheduled.
Example Managed Schedule
await cron.createSchedule({
name: 'Review invoices reminder',
type: 'cron',
source: 'agent',
sourceId: 'agent',
createdBy: 'agent',
visibility: 'user',
timezone: 'Europe/Rome',
cronExpression: '0 9 * * 1',
missedRunPolicy: 'skip',
concurrencyPolicy: 'skipIfRunning',
taskType: 'reminder.show',
taskInput: { message: 'Review invoices' },
});
This creates an active schedule that runs every Monday at 09:00 in the
Europe/Rome timezone. During creation, the scheduler validates the cron
expression, computes the first nextRunAt, persists the schedule, and emits
schedule.created.