Skip to content

Latest commit

 

History

History
310 lines (249 loc) · 14.6 KB

SCHEDULES.md

File metadata and controls

310 lines (249 loc) · 14.6 KB

Scheduled Tasks (native cron)

odek can run agent tasks on a cron schedule and deliver each result somewhere — a Telegram chat or stdout. The scheduler is native and in-process: it runs inside a long-lived odek process that has already resolved its configuration (API key, model, bot token, default chat) into memory. A scheduled task therefore sees exactly what an interactive odek run does — no environment-inheritance problems, no external cron daemon, no container-only behaviour.

# A weekday stand-up nudge delivered to Telegram
odek schedule add --cron "0 9 * * 1-5" --deliver telegram "Remind me: stand-up in 15 minutes"

# A weekly proactive-nudge digest from Extended Memory (opt in with
# memory.extended.proactive_nudges_enabled: true first)
odek schedule add --cron "0 9 * * 1" --deliver telegram "Review my proactive nudges (open questions, stale goals) and summarize what deserves attention this week"

# Run the scheduler (headless), or just start `odek telegram` — it hosts one too
odek schedule daemon

Where it runs

The same engine runs in two places; pick whichever fits your deployment:

Use when
Inside odek telegram You already run the bot. The scheduler starts automatically as part of the bot process — one process for chat + reminders.
odek schedule daemon You don't run the bot (headless server, CI box). A dedicated foreground process that only schedules.

A shared lock (~/.odek/schedule.pid) coordinates the two so jobs never fire twice — but the two sides handle contention differently: if a daemon already holds the lock, the bot's embedded scheduler defers silently (the bot keeps running, just without scheduling); if the bot holds it, a standalone odek schedule daemon refuses to start and exits non-zero. (Disable the bot's embedded scheduler with schedules.enabled = false if you prefer to run the daemon separately.)

Both also host the storage-maintenance janitor, which expires old sessions, audit records, and plans and rotates oversized logs — see MAINTENANCE.md.


Managing jobs

odek schedule list                          List jobs: id, on/off, cron, next fire, last status
odek schedule add --cron "<expr>" <task>    Add a job (flags below)
odek schedule rm <id>                       Remove a job
odek schedule enable  <id>                  Enable a job
odek schedule disable <id>                  Disable a job (kept, never fires)
odek schedule run  <id>                     Run a job once now and deliver (test it)
odek schedule next <id|"cron-expr">         Preview the next few fire times
odek schedule daemon                        Run the scheduler in the foreground

add flags

Flag Meaning
--cron "<expr>" 5-field cron or @macro (required)
--name <label> Human label (defaults to the first words of the task)
--deliver <dest> stdout (default), log, telegram, or telegram:<chatID>
--tz <IANA> Timezone, e.g. Europe/Berlin (default UTC)
--catchup If a fire was missed while the process was down, run once on startup
--disabled Add without enabling

Flags must come before the task text. odek schedule add rejects any flag-shaped token (one starting with -) after the task unless the task is quoted as a single argument or placed after --, as with odek run; otherwise the token would silently become part of the stored task. Both of these work:

odek schedule add --cron "0 9 * * 1" "Summarize the --verbose flag changes"
odek schedule add --cron "0 9 * * 1" -- Summarize the --verbose flag changes

Definitions are stored in ~/.odek/schedules.json (mode 0600); runtime state (last run, status, next fire) lives in ~/.odek/schedule-state.json. A running scheduler picks up edits to the definitions file automatically (no restart).


Managing from Telegram

When you run odek telegram, the same jobs can be managed from inside the chat — no shell access needed. Two slash commands mirror the CLI:

/schedules                                  List jobs (id, on/off, cron, next fire, last status)
/schedule add <cron> <task> [| opts]        Add a job (delivered to this chat by default)
/schedule view <id>                         Show a job's full detail + recent status
/schedule next <id|cron>                    Preview the next few fire times
/schedule run <id>                          Run a job once now, in this chat
/schedule enable | disable <id>             Toggle a job
/schedule rm <id>                           Remove a job

Because a cron expression has a fixed shape, add needs no quoting: an @macro is one token and a classic expression is exactly five fields; the rest of the line is the task. Options come after a literal |:

/schedule add 0 9 * * 1-5 Summarize my unread email
/schedule add @daily Daily standup digest | tz=Europe/Berlin name=standup
/schedule add */15 9-17 * * 1-5 Check the build | deliver=telegram catchup

| Option (after |) | Meaning | |---|---| | deliver=<dest> | stdout, log, telegram, or telegram:<chatID>. Default: this chat. | | tz=<IANA> | Per-job timezone, e.g. Europe/Berlin | | name=<label> | Human label (single token; default: first words of the task) | | catchup | Run a missed fire once on startup. Bare flag or catchup=true; catchup=false leaves it off | | disabled | Add without enabling. Bare flag or disabled=true; disabled=false keeps the job enabled |

Notes:

  • Delivery defaults to the current chat (unlike the CLI, which defaults to stdout) — adding a job from a conversation sends its results back there.
  • A delivered result is also recorded into the chat's existing conversation. The recorded turn carries the run's taint: if the scheduled run ingested untrusted content (or its provenance is unknown), the chat session is marked as having ingested untrusted content and its episodes are kept out of automatic recall.
  • /schedule run executes the task now, in this chat, through the normal agent pipeline (you see progress and answer any approval prompts) — a safe way to test a job. It does not change the job's own schedule or delivery.
  • Edits made from Telegram take effect immediately (the embedded scheduler reconciles on the spot, not on the ~30 s poll).
  • Only chats/users on the bot's allowlist (ODEK_TELEGRAM_ALLOWED_CHATS / ALLOWED_USERS) reach these commands.
  • Schedule management is further restricted to operator chats/users. Mutating commands (add, rm, enable, disable, run) are allowed only from the IDs listed in schedules.telegram_admin_chats / schedules.telegram_admin_users. These same IDs also authorize /restart. When neither list is configured, the bot falls back to telegram.default_chat_id; if that is also unset, mutating commands and /restart are rejected (read-only list/view/next still work). To keep schedule management CLI-only entirely, set schedules.allow_telegram_management = false.

Cron syntax

Standard 5-field Vixie cron:

┌ minute        0-59
│ ┌ hour        0-23
│ │ ┌ day-of-month 1-31
│ │ │ ┌ month   1-12 or JAN-DEC
│ │ │ │ ┌ day-of-week 0-6 or SUN-SAT (0 and 7 are both Sunday)
* * * * *

Each field accepts *, a value, a range a-b, a step */n / a-b/n / a/n, and comma-separated lists. Macros: @hourly, @daily (@midnight), @weekly, @monthly, @yearly (@annually).

Granularity is one minute (no seconds field). Times are in the job's --tz or, failing that, the scheduler's default timezone (UTC unless configured). The same default is used when odek schedule and the Telegram /schedule commands display fire times.

Daylight-saving changes follow Vixie/cronie behavior. A job with a fixed time of day (neither the minute nor the hour field is a wildcard, e.g. 30 1 * * *) fires once on a fall-back day, at the first occurrence of the repeated wall time, and a wall time that does not exist on a spring-forward day (e.g. 30 2 * * * on the gap day) fires at the first minute after the gap instead of being skipped. Wildcard jobs (*/5 * * * *, 0 * * * *) step real minutes and keep firing every matching minute, including the repeated hour.

Day-of-month / day-of-week coupling follows Vixie semantics: when both fields are restricted, a day matches if either matches. So 0 0 13 * 5 fires on the 13th or any Friday — not only Friday the 13th.

odek schedule next "0 9 * * 1-5"   # validate an expression and see upcoming fires (in schedules.timezone, UTC when unset)

Delivery

Kind Result goes to
stdout the daemon's stdout (or the bot's container logs)
log compatibility alias for daemon stdout (redacted)
telegram the configured telegram.default_chat_id
telegram:<chatID> a specific chat

Telegram delivery needs telegram.bot_token and a chat ID (ODEK_TELEGRAM_BOT_TOKEN + ODEK_TELEGRAM_DEFAULT_CHAT_ID, or per-job telegram:<chatID>). When delivering from inside odek telegram, the live bot client is reused (shared rate limiting).


Safety: unattended tasks

A scheduled task runs with no human present to approve actions. The headless runner forces non_interactive to deny and applies a non-overrideable safety floor: the destructive, blocked, persistence (deferred-execution writes: shell profiles, git hooks, cron, CI lifecycle scripts), and unread_exec (executing a script whose contents were not read in the session) classes are always denied. Schedule-specific policy in schedules.dangerous can allow or deny the remaining classes — network_egress, network_upload, system_write, code_execution, install, and unknown are the ones an unattended run can be granted — but the floor itself cannot be lifted. Every class that prompts by default is therefore denied in a scheduled run until schedules.dangerous allows it. network_egress is allowed by default, but network_upload (a webhook POST, curl -d @file, an rsync or scp to a backup host, a listener or tunnel) is not: a job that must upload needs schedules.dangerous.classes: {"network_upload": "allow"}. An upload also carries network_egress, so denying either class denies it. The same command analysis as interactive runs applies, including secret-read gating (a reference to a token-shaped environment variable or a credential file is a system_write, denied unless granted). This prevents a compromised task definition from erasing files, installing persistence, or running unreviewed scripts while unattended.

Read/summarise/deliver tasks work as usual. If you truly need a scheduled job that performs high-risk operations, run it interactively via odek run or the /schedule run command so an approver can review each action.

Task definitions in schedules.json are owner-authored (same trust level as config.json); the file is written 0600. Delivery output is redacted before it is written to stdout. The unified runtime log never stores that output.


Configuration

The schedules config section (in ~/.odek/config.json; project ./odek.json may only set enabled/timezone) tunes the engine. Every field also has an ODEK_SCHEDULES_* environment override. Project files cannot raise max_concurrent, set catchup, toggle Telegram management, or replace telegram_admin_* lists.

{
  "schedules": {
    "enabled": true,
    "max_concurrent": 2,
    "timezone": "UTC",
    "catchup": false,
    "allow_telegram_management": true,
    "telegram_admin_chats": [123456789],
    "telegram_admin_users": [987654321]
  }
}
Field Env Default Meaning
enabled ODEK_SCHEDULES_ENABLED true Run the embedded scheduler inside odek telegram
max_concurrent ODEK_SCHEDULES_MAX_CONCURRENT 2 Max jobs running at once
timezone ODEK_SCHEDULES_TIMEZONE UTC Default timezone for jobs without --tz
catchup ODEK_SCHEDULES_CATCHUP false Global default for the missed-run policy
allow_telegram_management ODEK_SCHEDULES_ALLOW_TELEGRAM_MANAGEMENT true Allow the in-chat /schedule commands to add/remove/toggle/run jobs (read-only listing always works)
telegram_admin_chats ODEK_SCHEDULES_TELEGRAM_ADMIN_CHATS [] Operator chat IDs that may use mutating /schedule commands
telegram_admin_users ODEK_SCHEDULES_TELEGRAM_ADMIN_USERS [] Operator user IDs that may use mutating /schedule commands

Unattended danger policy (schedules.dangerous)

The dangerous subsection overrides the base dangerous policy for scheduled runs — same schema (per-class actions, allowlist, denylist, action, non_interactive), overlaid on the global config before the safety floor is applied. It is operator-only: project-level ./odek.json cannot set it.

{
  "schedules": {
    "dangerous": {
      "classes": { "network_upload": "allow", "system_write": "deny" },
      "allowlist": ["go test ./...", "go build ./..."]
    }
  }
}

denylist entries use the same token-prefix matching as the global policy (see Denylist matching), and each run's read ledger is dropped when the run ends.

Every field also has an ODEK_SCHEDULES_DANGEROUS_* environment override:

Env Format
ODEK_SCHEDULES_DANGEROUS_CLASSES JSON object, e.g. {"network_egress":"allow","system_write":"allow"}
ODEK_SCHEDULES_DANGEROUS_ALLOWLIST Comma-separated command strings
ODEK_SCHEDULES_DANGEROUS_DENYLIST Comma-separated command strings
ODEK_SCHEDULES_DANGEROUS_ACTION Global default action: allow, deny, or prompt
ODEK_SCHEDULES_DANGEROUS_NON_INTERACTIVE allow, deny, or read_only (ignored: scheduled runs force deny)

See CONFIG.md for the full field reference.


Missed runs

If the scheduler was down when a job was due, on startup it either skips (default — reschedules forward and records a skipped status) or runs once (when the job's --catchup or schedules.catchup is set). A burst of missed ticks never stampedes: at most one catch-up fire per job. A skipped fire does not touch the job's recorded last run: last_run and last_result keep describing the last time the job actually ran. last_error is cleared (it is set only with an error status) and skipped_at records when the skip happened; the Telegram job view shows it. The next real run clears skipped_at.

Time a job spends disabled is never a missed run: disabling a job drops its projected next fire, so re-enabling it resumes at the next regular slot without a catch-up fire or a skipped record.