Adds markdown-based scheduled-task definitions ("loops") discovered from
.agents/loops/*.md (project scope, ancestor directories up to the
worktree root) and ~/.agents/loops/*.md (user scope), mirroring the
skills discovery pattern.
File format: YAML frontmatter (name, schedule cron, enabled, model as
provider/model, optional agent/timezone) plus the markdown body as the
execution prompt. Discovery and parsing live in
scheduled-tasks/loops.js; project-config gains reconcileLoopTasks which
runs inside the project write lock on every syncProject:
- identity by task name; a loop takes over a matching task, preserving
its id and runtime state (markdown wins on conflict with JSON)
- tasks whose loopFile is gone are unscheduled; JSON tasks are never
removed
- new loops are created under deterministic loop:<scope>:<name> ids
- project scope shadows user scope on name collisions
- malformed files are skipped with a warning and never block valid ones
Runtime state stays in the project config/state store; it is never
written to the markdown files. Module documentation updated with the
file format and reconciliation rules.
Fixes #2583
109 lines
4.5 KiB
Markdown
109 lines
4.5 KiB
Markdown
# Scheduled Tasks module
|
|
|
|
Server-owned scheduled task runtime and routes for OpenChamber-only automation.
|
|
|
|
## Scope
|
|
|
|
- Per-project scheduled task persistence is owned by `packages/web/server/lib/projects/project-config.js`.
|
|
- Markdown loop discovery/parsing is owned by `packages/web/server/lib/scheduled-tasks/loops.js`.
|
|
- Runtime orchestration and execution is owned by `packages/web/server/lib/scheduled-tasks/runtime.js`.
|
|
- This module is OpenChamber feature logic; it is intentionally separate from OpenCode proxy/runtime internals.
|
|
|
|
## Files
|
|
|
|
- `packages/web/server/lib/scheduled-tasks/runtime.js`
|
|
- Next-run computation (daily/weekly/cron compatibility)
|
|
- Timer scheduling and queueing
|
|
- Concurrency controls
|
|
- Session create + prompt_async execution
|
|
- Emits OpenChamber task-run events
|
|
|
|
- `packages/web/server/lib/scheduled-tasks/loops.js`
|
|
- Discovery of `.agents/loops/*.md` (project scope, ancestors up to the worktree root) and `~/.agents/loops/*.md` (user scope)
|
|
- Frontmatter parsing into scheduled-task definitions
|
|
- `syncProject` reconciles discovered loops with the persisted task list on every project sync (startup, task save/delete)
|
|
|
|
- `packages/web/server/lib/scheduled-tasks/routes.js`
|
|
- Scheduled task CRUD endpoints
|
|
- Manual run endpoint
|
|
- OpenChamber events SSE stream endpoint
|
|
|
|
## Loop file format
|
|
|
|
Portable, git-commit-able scheduled-task definitions:
|
|
|
|
```markdown
|
|
---
|
|
name: daily-digest
|
|
schedule: "0 9 * * *"
|
|
enabled: true
|
|
model: anthropic/claude-sonnet-4-5
|
|
agent: plan
|
|
timezone: Europe/Kyiv
|
|
---
|
|
Summarize repository changes since yesterday.
|
|
```
|
|
|
|
Field mapping (model: `packages/ui/src/lib/scheduledTasksApi.ts`):
|
|
|
|
| Frontmatter | Task field |
|
|
|---|---|
|
|
| `name` | `name` (required) |
|
|
| `schedule` | `schedule.kind: "cron"` + `schedule.cron` (required, cron-only in the portable format) |
|
|
| `enabled` | `enabled` (default `true`) |
|
|
| `model` | split on the first `/` into `execution.providerID` / `execution.modelID` (required) |
|
|
| `agent` | `execution.agent` (optional) |
|
|
| `timezone` | `schedule.timezone` (optional, IANA; defaults to the server zone) |
|
|
| body | `execution.prompt` (required) |
|
|
|
|
`thinking_level` and `goalEnabled`/`goalTokenBudget` are not part of the portable
|
|
format (UI/JSON-only today); `daily`/`weekly`/`once` schedules remain UI/JSON-only.
|
|
Runtime state (`lastRunAt`, `nextRunAt`, `lastStatus`, `lastError`, `lastSessionId`,
|
|
`lastDurationMs`) is never written to the markdown file — it continues to live in
|
|
the project config state store.
|
|
|
|
## Loop reconciliation rules
|
|
|
|
`projectConfigRuntime.reconcileLoopTasks(projectID, loops)` runs inside the
|
|
project write lock on every `syncProject` when the project path is known:
|
|
|
|
- **Identity is the task name.** A loop whose name matches an existing task
|
|
takes that task over: its schedule/execution/enabled are overwritten from the
|
|
file while the task's `id` and runtime `state` are preserved (markdown wins
|
|
on conflict with JSON-configured tasks).
|
|
- **Deletion.** A task carrying the `loopFile` marker whose loop file is no
|
|
longer discovered (removed or renamed) is unscheduled (removed from the
|
|
config). The marker is persisted in the config file, so removal is detected
|
|
across restarts. JSON-configured tasks without the marker are never removed.
|
|
- **Creation.** Loops without a matching task are created under a deterministic
|
|
`loop:<scope>:<name>` id so runtime state survives restarts.
|
|
- **Scope precedence.** Project-scope loops shadow user-scope loops with the
|
|
same name; among project files the nearest ancestor wins.
|
|
- **Malformed files** (missing `name`/`schedule`/`model`/body, invalid cron,
|
|
unreadable) are skipped with a warning and never block valid loops in the
|
|
same or other scopes.
|
|
- **UI edits** to a loop-sourced task are preserved in the config but the loop
|
|
file remains authoritative: the next reconciliation re-applies the file's
|
|
definition (including `enabled`). Use `enabled: false` in the file to disable.
|
|
|
|
## Public exports (runtime.js)
|
|
|
|
- `createScheduledTasksRuntime(dependencies)`
|
|
- Returned API:
|
|
- `start()`
|
|
- `stop()`
|
|
- `syncAllProjects()`
|
|
- `syncProject(projectId)`
|
|
- `runNow(projectId, taskId)`
|
|
|
|
## Public exports (routes.js)
|
|
|
|
- `registerScheduledTaskRoutes(app, dependencies)`
|
|
- Registers:
|
|
- `GET /api/projects/:projectId/scheduled-tasks`
|
|
- `PUT /api/projects/:projectId/scheduled-tasks`
|
|
- `DELETE /api/projects/:projectId/scheduled-tasks/:taskId`
|
|
- `POST /api/projects/:projectId/scheduled-tasks/:taskId/run`
|
|
- `GET /api/openchamber/scheduled-tasks/status`
|
|
- `GET /api/openchamber/events`
|