feat(scheduler): scheduled jobs inside skills (.volcanic/skills/<skill>/jobs/<job>.md)

Skill directories may now declare prompt or watcher jobs as files (cron or one-off at).
The file is the source of truth: the scheduler re-reads every chat's jobs/ every minute,
around agent runs and before /jobs and /skills, and schedules, re-plans or removes them.
Ids are <skill>/<job>; run state lives in .volcanic/scheduler/skill-jobs-state.json, never
in jobs.json. Watchers run from the skill directory with VOLCANIC_WORKSPACE and
VOLCANIC_SKILL_DIR. Broken files are shown with the reason in /jobs, /skills, schedule list
and the new <skill_jobs> prompt section; cancel answers with the file to edit. Every part of
a skill directory is optional; file reading and frontmatter parsing move to Abstractions.
This commit is contained in:
EugeneTes
2026-10-14 12:54:40 +00:00
parent 6893ee8197
commit 6e3241fed9
20 changed files with 1410 additions and 123 deletions

View File

@@ -0,0 +1,49 @@
# Skill jobs — design
## Goal
Let a skill declare scheduled jobs as files: `<workspace>/.volcanic/skills/<skill>/jobs/<job>.md`, prompt or watcher,
cron or one-off `at`. Every part of a skill directory (SKILL.md, INSTRUCTIONS.md, scripts, `jobs/`) is optional, in any
combination. The owner's spec (2026-10-14) was accepted with the eight amendments below.
## Decisions
- **One scheduler, separate source.** Skill jobs run in the process-wide `JobScheduler`: one timer, the same script
runner, script environment (`GOOGLE_ACCESS_TOKEN`), redaction, failure alerts without repeats, and the skip of an
occurrence while the previous run is in flight. They are never written to `jobs.json`: the file is the source of
truth. `SyncSkillJobsAsync(target)` re-reads the target's files and adds, re-plans (definition changed) or removes
(file gone, `enabled: false`, broken) entries; an unchanged definition keeps its next run. Entries carry
`ScheduledJob.Skill` (`[JsonIgnore]`), and their table key includes the target because `<skill>/<job>` is only unique
per chat.
- **Sync points**: on start (before overdue one-offs fire), every `Scheduler:SkillJobSyncInterval` (1 min) for every
deliverable known target, in `SchedulerExtension` on `before_agent_start` and `agent_end` (a file the agent just
wrote is in `/jobs` at once), and before `/jobs`, `/skills` and `schedule list`.
- **State** per workspace in `.volcanic/scheduler/skill-jobs-state.json`: last run and result, the `at` text that fired
(`doneAt`), and the `at` text last seen with when it was first seen. State of deleted files is dropped.
- **Amendments (accepted)**:
1. The id comes from paths only: `<skill dir>/<file name without .md>`. `name` is a label.
2. The header carries the file: `[Scheduled job "<skill>/<job>" (skill job, .volcanic/skills/<skill>/jobs/<job>.md)]`;
the failure alert too.
3. An `at` already past (more than 60 s) when the bot first saw that `at` never fires and is shown as not
scheduled; an `at` seen before it was due and missed while the bot was down fires late on start.
4. Sync every minute plus before and after each run (above).
5. File reading and the simplified frontmatter parser live in Abstractions (`SkillFiles`, `Frontmatter`), shared by
the core's `SkillCatalog` and the scheduler. Job files additionally cut YAML comments (`#` after whitespace).
6. The format and the chat's skill jobs go into the system prompt from `SchedulerExtension` (`<skill_jobs>`), so
sessions without the scheduler never advertise them.
7. Prompt text up to 16 KB.
8. `enabled: false` unschedules the job; it stays listed as `disabled`.
- **No frequency guard**: cron has 5 fields (no seconds), so once a minute is the floor, and overlapping runs are
skipped already.
- **Limit**: `Scheduler:MaxSkillJobsPerTarget` (50), separate from `MaxJobsPerTarget`; beyond it, files are listed as
over the limit. At most 200 job files are read per workspace.
- **Watchers** run from the skill directory with `VOLCANIC_WORKSPACE` and `VOLCANIC_SKILL_DIR` added after the
environment filter; those paths are not redacted (only the host's extra values are).
- **Control is by files only**: `/cancel` and `schedule cancel` on a skill job id answer with the file to edit.
- **Errors aren't silent**: missing `kind`, neither or both of `cron`/`at`, a watcher without `command`, an invalid
cron, `at`, `timeoutSeconds` or `enabled` → not scheduled, shown with the reason in `/jobs`, `/skills`, `schedule
list` and the prompt section.
## User docs
`docs/telegram-bot.md#skill-jobs`, and the skill directory rules under `#skills`.

View File

@@ -40,7 +40,9 @@ settings use the `GoogleCalendar` section, the scheduler the `Scheduler` section
| `Scheduler:Enabled` | `true` | Scheduled prompts and watcher scripts (see [Scheduling](#scheduling)) |
| `Scheduler:LegacyStorePath` | `<agent dir>/scheduler/jobs.json` | The retired shared job file. Jobs live in each chat's `<workspace>/.volcanic/scheduler/jobs.json`; on start the jobs found here move into their chats' files and this file is renamed `jobs.json.migrated-<timestamp>` |
| `Scheduler:DefaultScriptTimeout` | `00:05:00` | Watcher timeout when a job sets none (1 s to 1 h) |
| `Scheduler:MaxJobsPerTarget` | `50` | Jobs per chat |
| `Scheduler:MaxJobsPerTarget` | `50` | Jobs per chat (the `schedule` tool's) |
| `Scheduler:MaxSkillJobsPerTarget` | `50` | Skill jobs per chat, counted apart (see [Skill jobs](#skill-jobs)) |
| `Scheduler:SkillJobSyncInterval` | `00:01:00` | How often every chat's skill job files are re-read |
| `Scheduler:Shell` | `/bin/sh -c` (`cmd.exe /d /s /c` on Windows) | The watcher shell as a list, command appended; same as `bash` by default |
| `OPENAI_API_KEY` / `Telegram:Speech:ApiKey` | — | Turns voice transcription and the `voice` tool on. Hidden from `bash` and redacted from tool output |
| `STT_MODEL` / `Telegram:Speech:Model` | `gpt-4o-transcribe` | `gpt-4o-mini-transcribe` and `whisper-1` also work |
@@ -195,8 +197,12 @@ blocks in directory order. It is re-read on every run, so an edit counts from th
SKILL.md `name`, else the directory name. A directory may hold only an `INSTRUCTIONS.md`; it is then not listed among
the skills, but its rules still apply. Limits: 16 KB (16,384 characters) per file and 64 KB (65,536) for all
files together; a file over its share is cut and the prompt says `[…truncated, read the full file at <path>]`, and
once the total is used up, later files appear with only that note. Empty and unreadable files are skipped. `/skills`
marks skills with `(+ INSTRUCTIONS.md)` and lists instruction-only directories as `INSTRUCTIONS.md only`.
once the total is used up, later files appear with only that note. Empty and unreadable files are skipped.
Every part of a skill directory is optional: a directory is a skill when it holds a `SKILL.md`, an `INSTRUCTIONS.md`
or a `jobs/` directory ([skill jobs](#skill-jobs)), in any combination. `/skills` lists every such directory in
directory order: a skill with a SKILL.md as `• name – description (+ INSTRUCTIONS.md, 2 jobs)`, any other as
`• dir – INSTRUCTIONS.md, 2 jobs`, and a broken job file under its skill as `⚠️ <file>: <problem>`.
```markdown
---
@@ -248,6 +254,53 @@ bot is stopped; a job in a chat's file always belongs to that chat. Watcher fail
conversation, so the agent can't see them later. With
`Scheduler:Enabled=false`, `/jobs` and `/cancel` reply that scheduling is turned off, and `/timezone` still works.
#### Skill jobs
A skill can declare scheduled jobs as files, one per job: `<workspace>/.volcanic/skills/<skill>/jobs/<job>.md`
(subdirectories of `jobs/` aren't read). The skill needs nothing else: `jobs/` alone is enough. The job's id is
`<skill>/<job>` (directory name and file name without `.md`, whatever `name` says), so a restart or an edit never
duplicates it.
```markdown
---
kind: prompt # prompt | watcher, required
cron: "0 8 * * 1-5" # recurring, 5 fields, chat's zone; or instead
at: 2026-10-20T09:00 # one-off, ISO 8601; without an offset in the chat's zone
command: python3 scripts/check.py # watcher only, required there
timeoutSeconds: 60 # watcher only, 1–3600, default 300
enabled: true # false: the job is off
name: Morning digest # optional label (default: the file name)
description: Weekday summary # optional, shown in /jobs
---
For a prompt job: what the agent should check and write when it fires (up to 16 KB).
```
Exactly one of `cron` and `at`. The frontmatter is the same simplified YAML as SKILL.md, and `#` after a space starts a
comment. A watcher's body is ignored (use it for notes).
- **The file is the source of truth.** The bot re-reads every chat's `jobs/` every minute, before and after each agent
run, and when `/jobs` or `/skills` is used: a new file is scheduled, an edit re-plans the job, and a deleted file or
`enabled: false` takes it off. No restart is needed. The agent can create jobs itself by writing these files; its
system prompt has the format and this chat's skill jobs in a `<skill_jobs>` section.
- **prompt**: the body reaches the agent as `[Scheduled job "<skill>/<job>" (skill job, .volcanic/skills/<skill>/jobs/<job>.md)]`
followed by the text, like a `schedule` prompt.
- **watcher**: the bot runs `command` itself, from the **skill directory** (so `scripts/check.py` is relative to the
skill), with `VOLCANIC_WORKSPACE` (the chat's workspace) and `VOLCANIC_SKILL_DIR` (the skill directory) added to the
usual watcher environment (filtered like `bash`, `TZ`, `GOOGLE_ACCESS_TOKEN`). Output, exit codes and failure alerts
work as for other watchers; the header carries the file path.
- **One-off** (`at`): fires once, then it is done (recorded by the bot, not in the file) until `at` changes. One that
came due while the bot was down fires after the start with the "bot was offline" note. An `at` that was already past
when the bot first saw the file (for example a copied skill) never fires and is shown as not scheduled. Missed
`cron` occurrences aren't caught up.
- **Errors aren't silent**: a file without `kind`, with neither or both of `cron`/`at`, a watcher without `command`, a
bad cron, `at` or `timeoutSeconds` isn't scheduled and appears in `/jobs` and `/skills` with the reason.
- **Managed through files only.** `/jobs` and the `schedule` tool's `list` show skill jobs in their own block (id, kind,
schedule, next run, `disabled`, `done` or the error); `/cancel <skill>/<job>` and `schedule cancel` answer with the
file to edit (`enabled: false`). Up to 50 skill jobs per chat (`Scheduler:MaxSkillJobsPerTarget`), separate from the
50 `schedule` jobs; the rest are listed as over the limit.
- The bot's own record (last run and result, fired one-offs, when it first saw each `at`) is
`.volcanic/scheduler/skill-jobs-state.json` in the workspace; skill jobs never go into `jobs.json`.
#### Importing VolcanicSecretary jobs
Copy VolcanicSecretary's `.secretary/jobs.json` to the chat's `.volcanic/scheduler/jobs.json`