Review fixes for the markdown loop feature: - Loop-owned tasks now adopt by loop file path, not task name, so renaming a loop (frontmatter name or UI rename) renames the task in place instead of leaving a stale duplicate that keeps running the old definition; orphan duplicates of the same file are unscheduled. - Unparseable loop files are reported to the scheduler as definition:null entries: a task whose file still exists is kept with its last good definition, and only a genuinely removed file unschedules it. Transiently malformed files (mid-edit, bad merge) no longer delete tasks or their runtime state. - Adoption preserves UI-only execution fields (goalEnabled, goalTokenBudget, permissionAutoAccept, variant) that the portable format does not define. - DELETE on a loop-sourced task now returns 400 with guidance to remove the loop file, instead of being silently undone by the next reconcile. - Loops default to enabled: false; discovery of repository content never auto-executes scheduled sessions unless the file explicitly enables them. Regression tests for each fix; DOCUMENTATION.md updated.
5.5 KiB
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
syncProjectreconciles discovered loops with the persisted task list on every project sync (startup, task save/delete)
- Discovery of
-
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:
---
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 false — a loop only runs when the file explicitly enables it; add enabled: true to activate) |
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. For loop-owned tasks (carrying the
loopFilemarker) identity is the loop file path: a loop takes its task over regardless of the task's current name, so renaming the loop (thenamefield, or a UI rename) renames the task in place instead of leaving a stale duplicate behind. A loop whose name matches a JSON task (noloopFile) takes that task over instead: its schedule/execution/enabled are overwritten from the file while the task'sidand runtimestateare preserved (markdown wins on conflict). - UI-only fields survive adoption. Execution fields the file format does
not define (
goalEnabled,goalTokenBudget,permissionAutoAccept,variant) are preserved from the task when a loop adopts it; only fields the file defines are re-applied. - Deletion. A task carrying the
loopFilemarker 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. A task whose loop file still exists but is currently unparseable is KEPT with its last good definition — a transiently malformed file (mid-edit, bad merge) never deletes a task or its runtime state. - Creation. Loops without a matching task are created under a deterministic
loop:<scope>:<name>id so runtime state survives restarts. At most one task is driven per loop file; orphan duplicates of the same file are unscheduled. - 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 reported to the scheduler asdefinition: nullentries and warned about; they 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). Useenabled: falsein the file to disable. Deleting a loop-sourced task through the API is rejected with a 400 — the loop file is the removal surface.
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-tasksPUT /api/projects/:projectId/scheduled-tasksDELETE /api/projects/:projectId/scheduled-tasks/:taskIdPOST /api/projects/:projectId/scheduled-tasks/:taskId/runGET /api/openchamber/scheduled-tasks/statusGET /api/openchamber/events