agent-cron is the local durable task scheduler surface for ccc-node. It is intentionally conservative: status and planning modes are read-only, and timer installation is a separate explicit step.
scripts/agent-cron.sh add <task-id> --schedule EXPR --prompt TEXT [flags] [--json]— create a task (validated, atomic write; no execution). See--helpfor flags (timezone, notify, allowed-tools,--success-exit-codes 0,1, payload--argv/--cwd/--model/--timeout-sec,--not-before,--max-runs,--keep-after-run,--disabled, ...).scripts/agent-cron.sh edit <task-id> [same flags as add] [--json]— set-only partial update (schedule/timezone re-validated; payload flags merge,--argvreplaces the whole argv). No clear semantics — unset by remove+add.scripts/agent-cron.sh remove|enable|disable <task-id> [--json]— store-only mutations; never execute, install timers, or send messages.scripts/agent-cron.sh list [--json]— inspect configured tasks.scripts/agent-cron.sh due [--at ISO8601] [--json]— read-only due/retry resolver.scripts/agent-cron.sh status [--at ISO8601] [--json]— read-only operator rollup for task health, retry wait/exhaustion, lock state, and last run state.scripts/agent-cron.sh run <task-id> --dry-run— execution-plan preview only.scripts/agent-cron.sh scheduler --dry-run— one scheduler tick preview.scripts/agent-cron.sh scheduler --execute— explicit one-shot execution path for an already-approved scheduler unit.
If headless work completes but its run-state commit fails, agent-cron reports
status=persist-failed and converts that run's lock into a non-expiring
quarantine. This prevents the next scheduler tick from executing and notifying
the same occurrence again. After repairing the task store, inspect the holder
with lock <task-id> --action probe --json, then explicitly clear it with the
matching run id: lock <task-id> --action release --run-id <run-id> --json.
The installer defaults to the existing Claude runner. Use --runner codex to
install the ephemeral Codex runner instead. Codex defaults to
CCC_CODEX_SANDBOX=read-only, sets non-interactive approval policy to never,
and never persists a Codex session. Broader sandboxes require an explicit
--codex-sandbox choice at installation time.
Tasks may set maxRuns to a positive integer. For those bounded tasks, every
completed headless invocation, successful or failed, increments durable
runCount; reaching the limit disables the task and cancels any pending retry.
This makes maxRuns: 1
safe for one-time LLM jobs without leaving an annually recurring cron enabled.
Set notBefore to the intended UTC activation timestamp so a newly-created
annual cron expression cannot catch up an occurrence from the previous year.
For example, a July 22 one-time job uses its normal five-field schedule,
notBefore: 2026-07-22T02:27:00Z, and maxRuns: 1.
schedule accepts four kinds (epic #584-adjacent cron upgrade, referencing the
Hermes and OpenClaw schedulers):
- Cron: 5-field expression or
@hourly|@daily|@weekly|@monthly|@yearly, matched in the task'stimezone(IANA name, e.g.Asia/Seoul; default UTC). - Interval:
every <N>m|h|d(min 1 minute, max 366 days). Free-running fromlastRunAt; setanchorAt(ISO8601) to phase-anchor occurrences (e.g. anchor..T00:15Z+every 1hfires at :15). A never-run interval task with no anchor is due immediately once. - One-shot:
at <ISO8601>or a bare ISO8601 timestamp. Naive timestamps are anchored to the tasktimezone. After a successful run the task is auto-disabled unlesskeepAfterRun: true. - Unknown timezones and malformed expressions fail closed as
invalid-scheduleindue/statusoutput;duerows exposescheduleKind.
Each task runs one payload (default: prompt, backward compatible):
- prompt (default): the existing headless Claude run of
promptviaclaude/headless.sh. Optionalpayload.modelis passed through as--model(viaCCC_MODEL). Wall-clock timeoutpayload.timeoutSec(default 3600s). - command:
payload.argvruns directly (no shell interpolation, no LLM token spend — watchdog/maintenance jobs). Optionalcwd,timeoutSec(default 600s),outputMaxBytes(default 64 KiB, capped capture).modelis rejected for command payloads.
A timed-out run records status timeout (exit code 124) and consumes the
normal retry policy. Cross-field payload rules (argv required for command,
argv/cwd rejected for prompt) are enforced fail-closed by validate and on
load.
By default only exit code 0 counts as success. A watch-type task that
exits non-zero to signal findings (e.g. a fleet node is DOWN) would be
mislabeled failed → retry-exhausted. Set --success-exit-codes 0,1 so
exit 1 is recorded as a successful run-with-findings; only codes outside the
set (2+, 127, 124-timeout) count as failed. status shows lastExitCode
so an operator can tell 1 (findings) from 127 (command missing) at a
glance. A task that declared no retryPolicy has no retry concept and is
never labelled retry-exhausted — its failures stay plain failed.
none(default) — no spool writes.telegram-owner— every run writes a short redacted owner-only spool entry.telegram-owner-on-failure— spool only non-success runs (failed/timeout); successful runs reportdelivery: skipped-success.telegram-chat/telegram-chat-on-failure(#665) — deliver to a specific group/channel chat instead of the owner DM. Requires--notify-chat-id <id>(numeric group id like-1001234567890, or@channelusername); the schema fails closed if the chat target is set without an id. The chat id must be on the allowlistCCC_AGENT_CRON_NOTIFY_ALLOWED_CHATS(CSV/JSON) — an out-of-allowlist target reportsdelivery: blocked-not-allowlistedand writes nothing. The spool record addsrecipient: chat+chatId; the same spool/redaction/audit path as owner delivery is reused (no per-task token handling). The bridge push notifier re-validates the chat id against the same allowlist on read before sending (defense in depth), so a forged spool file can never reach an un-allowlisted chat.
Owner/chat spool text uses the canonical credential patterns from this
checkout's bridge/utils/redaction.py, followed by the deliberately broader
agent-cron owner-spool masks for short bearer/assignment/near-token values and
arbitrary long runs. Redaction happens before the display-length cap. If the
canonical module cannot be loaded, task execution and history remain intact but
notification delivery reports blocked-redaction-unavailable and writes no
captured output to the spool.
For a non-success run, the already-redacted, bounded stdout/stderr are also
checked for the exact line-start fleet diagnostic tokens DOWN, UNREACHABLE,
DRIFT, and BOOTPATH. If any are present, the first line identifies a fleet
alert and includes only the validated task id plus deterministic token counts;
node names, paths, credentials, and the rest of each diagnostic row remain out
of the title. Failures with no recognized signal keep the generic status first
line, and successful notifications keep their existing text. This shared
formatting applies to existing command tasks such as adapter-fleet-watch and
fleet-doctor-sweep without a task-store field or migration.
Read-only/status modes never acquire locks, execute prompts, write bridge spools, install timers, edit crontab/systemd, send Telegram, call providers, or touch remotes. Execution mode may write task history and owner-only redacted spool entries, but still does not install timers or call Telegram/provider APIs directly. add/remove/enable/disable mutate only the validated task store via the same atomic private write path.
A tick that never returns is the failure mode to design against: Type=oneshot
defaults to TimeoutStartSec=infinity, so one stuck run pins the unit in
activating and the timer never fires again until an operator intervenes.
Two independent caps exist:
TimeoutStartSec=is always emitted byinstall-agent-cron-systemd.sh(default1800; override with--timeout-start SECorCCC_AGENT_CRON_TIMEOUT_START,infinityto opt out).- Both headless runners wrap the provider CLI in
timeout—CCC_HEADLESS_TIMEOUTseconds (default1500,0disables), exiting124when tripped. This is the only cap on Termux nodes, which have no systemd.
Task prompts must not instruct the agent to block on an external condition.
An observed real failure: a task told the agent to wait for a PR to become
mergeable, and it emitted until state=$(gh api …) && echo "$state" | grep -q '"m":true\|"m":false'; do sleep 5; done. The PR was then merged, GitHub began
returning mergeable: null for the closed PR, and the exit condition became
unreachable — the loop ran for 11 days. Prefer a bounded number of polls with an
explicit deadline, and treat "condition never became true" as a reportable
outcome rather than something to wait out.
schemas/agent-cron-task-store.schema.jsonis the structural source of truth.scripts/agent_cron_schema.pyapplies the schema fail-closed without an optional system Python dependency; duplicate task IDs are the only store-level semantic rule layered on top.scripts/agent_cron_model.pyowns pure task lookup and prompt-free list projections.scripts/agent_cron_repository.pyowns validated load and private atomic writes.scripts/agent_cron_lib.pyowns pure schedule and retry calculations.scripts/agent_cron.pyis an import-safe CLI composition root. Dispatch only runs throughmain(); importing it does not parse commands, print, or mutate the filesystem or process environment.
Planning functions remain read-only and produce explicit mutation metadata. The
runner applies locks, headless execution, history, retry state, and spool writes only
after an explicit run or scheduler --execute dispatch.
For fleet operations, collect each node's status --json output into evidence blocks and summarize only metadata: node, task id, lastStatus, retryEligibleAt, retry exhaustion, lock state, and safe error class. Do not collect prompts, memory contents, raw env, tokens, chat IDs, or provider output.