Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
68a7f00
repo: ignore .worktrees/ for project-local worktree workflow
ClaydeCode Sep 20, 2026
12e58d3
scheduler: add file-driven scheduled-tasks design spec
ClaydeCode Sep 20, 2026
1583809
scheduler: revise design spec — drop notify field, whole-library skil…
ClaydeCode Sep 21, 2026
1c89c48
scheduler: add implementation plan
ClaydeCode Sep 21, 2026
c2c12ff
repo: ignore .superpowers/ SDD scratch
ClaydeCode Sep 21, 2026
7818f9a
service: move job-execution core out of webhook, rename PebbleJob to Job
ClaydeCode Sep 21, 2026
7531fca
service: add Job.origin and rename process span to clayde.job.process
ClaydeCode Sep 21, 2026
af32963
service: run the CLI under auto permission mode instead of skip-permi…
ClaydeCode Sep 21, 2026
538827f
service: suppress success notification for scheduler-origin jobs
ClaydeCode Sep 21, 2026
697d4f1
service: origin-aware system and user prompt framing
ClaydeCode Sep 21, 2026
211cce6
service: discover SKILL.md directory skills, ignore reference markdown
ClaydeCode Sep 21, 2026
94ef167
scheduler: add croniter dependency and scheduler settings
ClaydeCode Sep 21, 2026
05ed83d
scheduler: task-file model, parsing, and discovery
ClaydeCode Sep 21, 2026
8b34696
scheduler: recurring-task run-state persistence
ClaydeCode Sep 21, 2026
ed12b1e
scheduler: pure due-ness and lateness helpers
ClaydeCode Sep 21, 2026
a421e23
scheduler: tick loop — enqueue due tasks, dedup, move one-offs
ClaydeCode Sep 21, 2026
55a4d44
orchestrator: run the scheduler loop alongside the webhook when enabled
ClaydeCode Sep 21, 2026
897fafc
scheduler: deployment mounts, config template, and docs
ClaydeCode Sep 21, 2026
8e70254
scheduler: persist per-tick dedup across a failing task
ClaydeCode Sep 21, 2026
097de30
scheduler: per-task timeout with a 4h cap
ClaydeCode Sep 21, 2026
7fb191a
scheduler: correct config template comment for now-honored timeout
ClaydeCode Sep 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ state.json
repos/
logs/

# Worktrees
.worktrees/

# Python
.venv/
__pycache__/
Expand All @@ -21,3 +24,6 @@ docker-compose.override.yml

# IDE
.idea

# SDD scratch
.superpowers/
32 changes: 26 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,15 +69,30 @@ src/clayde/
__init__.py
work.py # run(issue_url) — unified: Claude decides next action
# (ask, plan, implement, open PR, or address review)
webhook/
service/ # job-execution core, shared by the Pebble webhook and
# the scheduler
__init__.py
queue.py # Job (origin: "pebble" | "scheduler"), JobQueue
# (in-memory asyncio.Queue), QueueFullError
runner.py # invoke_claude_job — async CLI subprocess (auto
# permission mode), extract_notification_payload
skills.py # Skill model, /skills/ discovery (SKILL.md + flat
# builtin), origin-aware system/user prompt builders
notify.py # send_ntfy + NotificationPayload model
worker.py # worker_loop, process_job — pop jobs, OTel process
# span; skips the success-notify for origin=scheduler
webhook/ # thin HTTP layer — Pebble ingress only
__init__.py
app.py # FastAPI app, /webhook/pebble, /health, OTel enqueue span
auth.py # constant-time bearer-token verification
notify.py # send_ntfy + NotificationPayload model
queue.py # PebbleJob, JobQueue (in-memory asyncio.Queue), QueueFullError
runner.py # invoke_claude_pebble — async CLI subprocess, fresh session
skills.py # Skill model, /skills/ discovery, system + user prompt builders
worker.py # worker_loop, process_job — pop jobs, OTel process span
scheduler/
__init__.py
tasks.py # ScheduledTask, parse_task_file(), discover_tasks()
# — cron/at frontmatter under CLAYDE_SCHEDULER_DIR
state.py # load_state()/save_state(), get_last_fired()/
# set_last_fired() — /data/scheduler_state.json
loop.py # scheduler_loop()/run_tick() — due-ness, lateness
# annotation, enqueue, done/ move for fired one-offs
skills_builtin/
ping.md # built-in health-check skill (baked into image)

Expand Down Expand Up @@ -124,6 +139,11 @@ Plain `KEY=VALUE` file (no shell quoting). All keys use `CLAYDE_` prefix and are
| `CLAYDE_DISK_ALERT_THRESHOLD_PCT` | Usage % that triggers an ntfy alert (default `85`) |
| `CLAYDE_DISK_ALERT_PATH` | Path whose partition is checked — same volume as host root (default `/data`) |
| `CLAYDE_DISK_ALERT_COOLDOWN_S` | Min seconds between repeat alerts while over threshold (default `21600`) |
| `CLAYDE_SCHEDULER_ENABLED` | Set to `true` to enable the scheduled-task loop (default `false`) |
| `CLAYDE_SCHEDULER_DIR` | In-container dir scanned for task markdown files (default `/tasks`) |
| `CLAYDE_SCHEDULER_INTERVAL_S` | Scheduler poll interval in seconds (default `30`) |
| `CLAYDE_SCHEDULER_TZ` | Default IANA timezone for task frontmatter without its own `tz` (default `Europe/Berlin`) |
| `CLAYDE_SCHEDULER_TIMEOUT` | Per-task CLI timeout in seconds (default `300`) |

Config is loaded via `get_settings()` (singleton). `GH_TOKEN` is exported at startup for the `gh` CLI.

Expand Down
94 changes: 94 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,3 +236,97 @@ spawns a fresh Claude CLI session (no context carries between requests)
with `cwd` set to the knowledge-base mount. Claude is free to use any
number of skills per request; every terminal outcome (success, failure,
timeout, usage limit, queue full, etc.) emits an ntfy notification.

---

## Scheduler

Clayde can also run tasks on a schedule — recurring (cron) or one-off (a
single future time) — instead of waiting for a Pebble request. Scheduled
runs feed the same job queue and worker as the Pebble webhook, so they use
the same Claude CLI backend and permission mode; only the prompt framing and
notification behaviour differ (below).

To enable:

1. Create the task directory on the host: `mkdir -p ~/clayde-tasks`. It's
mounted **read-write** at `/tasks` (already wired in
`docker-compose.yml`) — read-write because fired one-off tasks are moved
into a `done/` subdirectory, not deleted.
2. Set `CLAYDE_SCHEDULER_ENABLED=true` in `data/config.env`. Other
`CLAYDE_SCHEDULER_*` keys (poll interval `INTERVAL_S`, default timezone
`TZ`, in-container task dir `DIR`, per-task CLI timeout `TIMEOUT`) have
working defaults — see `config.env.template` if you need to change them.
3. Drop one markdown file per task into `~/clayde-tasks/`:

```markdown
---
cron: "0 8 * * *" # recurring, 5-field cron
# at: 2026-09-21T08:00 # one-off, ISO-8601 local datetime (mutually exclusive with cron)
tz: Europe/Berlin # optional; default CLAYDE_SCHEDULER_TZ
enabled: true # optional; default true
title: keep-warm # optional; label for logs only
timeout: 4h # optional; default CLAYDE_SCHEDULER_TIMEOUT (300s)
---
Run a trivial health check and confirm you are alive.
```

Exactly one of `cron` or `at` is required — `cron` is a standard 5-field
expression; `at` is a local datetime interpreted in `tz`. Everything
after the frontmatter is the prompt sent to Claude. Malformed files
(missing/both schedule keys, bad cron, unterminated frontmatter) are
logged and skipped, not ntfy'd — that would spam every poll tick.

`timeout` sets this task's own CLI timeout, overriding
`CLAYDE_SCHEDULER_TIMEOUT` for that one job — useful for a long overnight
deep-research run that needs more than the default budget. It accepts a
duration (`4h`, `90m`, `45s`) or a bare number of seconds, and is
hard-capped at 4 hours; a requested value above the cap is clamped and
logged, not rejected. A malformed `timeout` value makes the whole file
malformed, same as a bad `cron`.

Long-running tasks should have their prompt instruct the agent to persist
progress periodically (e.g. write interim findings to the KB inbox as it
goes), not just at the end. A run that hits a usage limit or its timeout
is a single unattended attempt with no auto-resume, so whatever interim
state it wrote is all that survives.

A fired one-off task is moved to `~/clayde-tasks/done/<epoch>-<name>.md`
rather than deleted, so it stays as a record of what ran and when. Recurring
tasks are never moved; their last-fired time is tracked in the container's
own `/data/scheduler_state.json`, keyed by filename.

### Notifications

Unlike Pebble requests, a scheduler job stays **silent on success** — the
framework emits no ntfy for a clean run. The framework still ntfy's on
**failure** (timeout, usage limit, CLI error, auth error, worker crash),
because a run that didn't finish can't self-report. If a task should notify
on success (e.g. "call the dentist" or a genuine reminder), say so in the
task's own prompt and let the agent send it itself via the `ntfy-ping`
skill — there is no per-task notify field.

### Skill library mount

`docker-compose.yml` mounts the whole personal skill library read-only at
`/skills/kb`, alongside the existing `/skills/personal` and `/skills/shared`
Pebble skill dirs, so a scheduled task (or a Pebble request) can use any
skill from the knowledge base, including `ntfy-ping`.

### Permission mode

Both scheduled and Pebble jobs run the Claude CLI with
`--permission-mode auto --permission-prompts none` — Claude proceeds without
interactive approval, since nobody is watching an unattended run to answer a
prompt.

### Bootstrapping caveat

The scheduler presumes the Claude CLI login (see [Option B: Claude Code
CLI](#option-b-claude-code-cli-cli) above) is already established. A recurring
task that runs the CLI regularly keeps that login's OAuth refresh lineage
alive once it's ticking — but the login has to be created once, by hand,
*before* the first tick, and must not be left to lapse in the meantime. A
scheduler enabled against a login that was never created, or that expired
before its first run, fails with an auth error on every tick (which does
ntfy, per the failure behaviour above).
14 changes: 14 additions & 0 deletions config.env.template
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,17 @@ CLAYDE_NTFY_TIMEOUT_S=10
# --- Knowledge base (default cwd for Pebble runs) ---
# Mounted from host ~/knowledge_base/. Synced by Syncthing — no git in container.
CLAYDE_KB_PATH=/home/clayde/knowledge_base

# --- Scheduler ---
# Set to true to enable the scheduled-task loop (see README "Scheduler").
CLAYDE_SCHEDULER_ENABLED=false
# In-container directory scanned for task markdown files (default /tasks;
# mounted from host ~/clayde-tasks in docker-compose.yml).
CLAYDE_SCHEDULER_DIR=/tasks
# How often the scheduler checks for due tasks, in seconds (default 30).
CLAYDE_SCHEDULER_INTERVAL_S=30
# Default timezone for task frontmatter that omits its own tz (default Europe/Berlin).
CLAYDE_SCHEDULER_TZ=Europe/Berlin
# Default wall-clock budget (seconds) for a scheduled task.
# Override per task with `timeout:` frontmatter field; hard-capped at 4h (14400s).
CLAYDE_SCHEDULER_TIMEOUT=300
7 changes: 7 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,16 @@ services:
# under /skills/. Subdirectory layout is free; discovery is recursive.
- ~/skills/personal:/skills/personal:ro
- ~/skills/shared:/skills/shared:ro
# Whole personal skill library, read-only — lets scheduled tasks (and
# Pebble requests) use any skill from the knowledge base, e.g. ntfy-ping
# for an intentional success notification.
- ~/knowledge_base/skills:/skills/kb:ro
# Pebble knowledge-base working directory — Syncthing on the host
# handles cross-device sync; container performs no git on the KB.
- ~/knowledge_base:/home/clayde/knowledge_base
# Scheduled-task markdown files (cron/at frontmatter). Read-write: the
# scheduler moves fired one-off files into a done/ subdirectory.
- ~/clayde-tasks:/tasks
labels:
- "traefik.enable=true"
- "traefik.http.routers.clayde.rule=Host(`${CLAYDE_PEBBLE_HOST}`) && PathPrefix(`/webhook`)"
Expand Down
Loading
Loading