Cortex Scheduling System¶
The scheduling system lets you set up recurring or one-time agent invocations. Schedules can trigger LLM-based conversations, programmatic jobs (task dispatch, memory index rebuild, task archiving), or custom job handlers. Schedules persist to disk, survive restarts, and hot-reload when changed externally.
Schedule Types¶
Four trigger types are supported:
| Type | Description | Key Parameter | Example |
|---|---|---|---|
interval |
Fire every N time units | interval (e.g., "5m", "1h", "30s") |
Run a status check every 5 minutes |
daily |
Fire at a specific time each day | time (HH:MM 24-hour format) |
Run a daily digest at 09:00 |
weekly |
Fire on a specific day of week at a time | dayOfWeek (0-6, 0=Sun), time |
Run a weekly review Monday at 21:00 |
once |
Fire once after a delay | delay (duration string or ms) |
Send a reminder in 2 hours |
Duration strings follow the format <number><unit> where unit is s (seconds), m (minutes), h (hours), or d (days). Examples: "30s", "5m", "2h", "1d".
Schedule Records¶
Each schedule is stored as a JSON object in ~/.cortex/data/schedules.json:
{
"tasks": [
{
"id": "d6f1bb1e",
"type": "interval",
"message": "Check for new tasks and dispatch if available",
"channel": "C07ABCDEF",
"profile": "claude-haiku",
"intervalMs": 30000,
"createdAt": 1747680000000,
"nextRun": 1747680030000,
"lastRun": 1747680000000,
"dispatchType": "task-dispatch",
"target": { "kind": "fresh" },
"fallback": "fresh",
"preCheck": "test -f ~/.cortex/data/schedules.json"
},
{
"id": "e4c91a03",
"type": "interval",
"message": "Archive completed tasks older than 3 days",
"channel": "C07ABCDEF",
"profile": "claude-haiku",
"intervalMs": 21600000,
"dispatchType": "task-archive"
}
]
}
ScheduleTask Fields¶
| Field | Description |
|---|---|
id |
8-character hex identifier (auto-generated) |
type |
interval, daily, weekly, or once |
message |
The prompt to send when the schedule fires (a [Scheduled Task] prefix is added automatically at fire time) |
channel |
Slack channel ID where the task lands |
profile |
Agent profile name (defaults to the active profile) |
intervalMs |
For interval type: milliseconds between fires |
time |
For daily/weekly types: HH:MM 24-hour time |
dayOfWeek |
For weekly type: 0-6 (0=Sunday) |
runAt |
For once type: epoch milliseconds when to fire |
nextRun |
Computed epoch ms of next scheduled fire |
createdAt |
Epoch ms when the schedule was created |
lastRun |
Epoch ms of last successful fire |
lastSkipped |
Epoch ms of last skipped fire (preCheck failed) |
isPaused |
Whether the schedule is currently paused |
pausedAt |
Epoch ms when paused |
pausedBy |
"user" or "rate-limit" — who paused it |
dispatchType |
"task-dispatch", "memory-index-regen", "task-archive", or absent (default LLM invocation) |
preCheck |
Optional shell command; non-zero exit → skip this fire |
target |
Where the fired task should land (see Target Resolution below) |
fallback |
What to do if target is unavailable: "fresh" (default), "skip", or "wait" |
Dispatch Types¶
The dispatchType field controls what happens when a schedule fires:
| Dispatch Type | Behavior |
|---|---|
| (absent) | Default LLM path: sends the message to an agent for conversation |
task-dispatch |
Runs the task dispatch pipeline: selects, claims, and dispatches a task from TASKS.yaml |
memory-index-regen |
Rebuilds all experiment/knowledge/pattern index files |
task-archive |
Archives completed tasks older than 3 days |
The first two types (task-dispatch and programmatic handlers) go through a registered job runner. The default (no dispatchType) sends the message to the LLM runner with a [Scheduled Task] prefix.
Target Resolution¶
The target field controls where the scheduled task lands when it fires:
| Target Shorthand | Behavior |
|---|---|
fresh |
Always create a new thread (default). The schedule's channel is used as fallback |
current-channel |
Reuse the channel's active thread if one exists; otherwise create a default thread with the channel's session |
current-session |
Resume a specific named session (cortex-XXXX). If the session is gone, apply fallback |
current-thread |
Continue a specific thread by ID. If the thread is gone or not running/waiting, apply fallback |
The current-channel, current-session, and current-thread shorthands are resolved to concrete IDs at create time from the current execution context. Explicit target objects can also be used:
{ "kind": "fresh" }
{ "kind": "channel", "channel": "C07ABCDEF" }
{ "kind": "session", "sessionName": "cortex-a1b2c3", "sessionId": "sess_xyz", "channel": "C07ABCDEF" }
{ "kind": "thread", "threadId": "thr_a1b2c3d4", "channel": "C07ABCDEF" }
Fallback Behavior¶
When a session or thread target is no longer available at fire time, the fallback field determines what happens:
| Fallback | Behavior |
|---|---|
fresh |
Silently fall back to creating a new thread in the schedule's channel (default) |
skip |
Record lastSkipped, post a one-line Slack note, do not run the task |
wait |
Not yet implemented — currently treated as fresh |
PreCheck¶
The preCheck field is an optional shell command that acts as a gate: if the command exits with a non-zero status, the schedule's fire is skipped for that cycle. The schedule is rescheduled for its next normal interval — there is no fast retry.
The command runs via execSync with a 15-second timeout. It receives the PRECHECK_LAST_RUN environment variable (epoch ms of the task's lastRun field). The working directory is DATA_DIR (~/.cortex/).
Use cases for preCheck:
- Check if a required file exists before running:
test -f ~/.cortex/data/schedules.json - Check if a process is running:
pgrep -f "python train.py" - Check system load:
[ $(cat /proc/loadavg | cut -d' ' -f1 | cut -d. -f1) -lt 8 ]
Hot-Reload¶
The scheduler watches schedules.json for external changes via fs.watch. When a change is detected (after a 300ms debounce), it:
- Invalidates the in-memory cache
- Reads the fresh file from disk
- Diffs file task IDs against in-memory timer IDs
- Removes timers for tasks no longer in the file
- Adds timers for new tasks
- Updates timers for tasks whose scheduling config changed (detected via config hash comparison)
- Sends an admin notification to Slack:
:arrows_counterclockwise: schedules.json hot-reloaded: +N -M ~P task(s)
Self-write guard: When the scheduler itself writes to schedules.json (via add, remove, pause, etc.), it sets a _selfWriting flag for 100ms. The file watcher ignores changes during this window to avoid redundant hot-reloads.
Config Hash for Change Detection¶
Each task's scheduling-relevant fields are hashed: type, type-specific key (intervalMs/time/dayOfWeek), message, channel, profile, dispatchType, preCheck. If the hash of a task in the file differs from the in-memory hash, the timer is re-armed. This means edits to any scheduling field trigger an automatic re-schedule.
Before-Run Guard¶
In addition to preCheck (which is per-task), the scheduler supports a global beforeRunGuard callback set by app.ts. This guard is used for system-wide concerns like rate-limit throttling. When the guard returns true, the fire is blocked entirely. The _onGuardBlocked async callback handles bookkeeping (e.g., persisting the throttle state).
In-Flight Protection¶
Each task has an _inFlight flag. If a task's timer fires while a previous invocation is still running (detected because the task ID is in _inFlight), the new invocation is skipped. This prevents overlapping executions of the same schedule.
Pause and Resume¶
Pausing¶
Schedules of type interval, daily, and weekly can be paused. Once-type schedules cannot be paused (they either fire or get dropped).
When paused:
- isPaused is set to true, pausedAt records the timestamp, pausedBy records "user" or "rate-limit"
- nextRun is set to null
- The in-memory timer is cleared and not re-armed
The pausedBy field distinguishes user-initiated pauses from automatic rate-limit pauses. The rate-limit auto-resume path only considers pausedBy: "rate-limit" tasks.
Resuming¶
When resumed:
- isPaused is set to false, pausedAt and pausedBy are cleared
- nextRun is recomputed based on the schedule type (for interval: now + intervalMs; for daily/weekly: the next occurrence)
- The timer is re-armed
Removing¶
Schedules can be deleted by ID (idempotent — removing a non-existent schedule returns { removed: false }). The timer is cleared and the entry is removed from schedules.json.
Startup Behavior¶
On server startup, the scheduler:
- Drops
oncetasks that are more than 1 minute overdue (past theirrunAt) - Schedules all remaining tasks with their computed
nextRuntimes - Starts the file watcher for hot-reload
- Logs the total task count
MCP Tools¶
Schedules can be managed through MCP tools (used by the agent in Slack conversations):
| Tool | Description |
|---|---|
cortex_schedule_add |
Create a new schedule. Accepts type, message, interval/time/dayOfWeek/delay, target, fallback, profile, preCheck |
cortex_schedule_list |
List all schedules (default: 50) |
cortex_schedule_get |
Get a schedule by ID |
cortex_schedule_remove |
Delete a schedule by ID (idempotent) |
cortex_schedule_pause |
Pause a recurring schedule |
cortex_schedule_resume |
Resume a paused schedule |
The cortex_context MCP tool provides the current execution context (channel, sessionId, sessionName, threadId, profile, project, backend) that cortex_schedule_add uses for current-channel/current-session/current-thread target resolution.
Creating a Schedule via MCP¶
{
"type": "interval",
"message": "Check GPU status and report",
"interval": "10m",
"target": "current-channel",
"fallback": "fresh"
}
{
"type": "daily",
"message": "Run morning research scan",
"time": "08:00",
"profile": "claude-sonnet"
}
Slack Commands¶
The !schedule Slack command provides interactive schedule management.
For the underlying CLI tools, see cli-reference.md.
| Command | Description |
|---|---|
!schedule list |
List all schedules with status, next run time, and type |
!schedule add <type> <message> |
Add a new schedule interactively |
!schedule remove <id> |
Remove a schedule |
!schedule pause <id> |
Pause a schedule |
!schedule resume <id> |
Resume a paused schedule |
Job Registry¶
The scheduling system uses a job registry pattern (job-registry.ts) for programmatic dispatch. Job runners self-register at module import time:
register('scheduled-task', llmRunner);
register('task-dispatch', taskDispatchRunner);
register('memory-index-regen', memoryIndexRegenRunner);
register('task-archive', taskArchiveRunner);
This allows new job types to be added by creating a new job module that calls register() at import time — no changes needed to the scheduler core.
Rate-Limit Integration¶
The scheduler integrates with Cortex's rate-limit throttling:
- The
beforeRunGuardcallback can block fires when the system is rate-limited - Schedules can be auto-paused by the rate-limit system (
pausedBy: "rate-limit") - The throttle state (
resetsAt,activatedAt, affected modes) is stored inschedules.jsonalongside tasks - On startup, previously rate-limit-paused tasks are evaluated for auto-resume