Files
openchamber/packages/web/server/lib/scheduled-tasks/loops.js
T
Serhii Dziupin 2fcfe511a5 feat(tasks): support markdown scheduled-task loops in .agents/loops
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
2026-08-05 14:11:28 +03:00

190 lines
5.9 KiB
JavaScript

/**
* Markdown loops — portable scheduled-task definitions.
*
* Loops are git-commit-able markdown files with YAML frontmatter, discovered
* from `.agents/loops/*.md` (project scope, including ancestor directories up
* to the worktree root) and `~/.agents/loops/*.md` (user scope), mirroring the
* skills discovery pattern (`packages/web/server/lib/opencode/skills.js`).
*
* File format:
*
* ---
* name: daily-digest
* schedule: "0 9 * * *"
* enabled: true
* model: anthropic/claude-sonnet-4-5
* agent: plan
* timezone: Europe/Kyiv
* ---
* Summarize repository changes since yesterday and post the digest.
*
* Field mapping (see packages/ui/src/lib/scheduledTasksApi.ts):
* name -> task.name
* schedule -> task.schedule.kind "cron" + task.schedule.cron
* enabled -> task.enabled (default true)
* model -> split into task.execution.providerID / task.execution.modelID
* agent -> task.execution.agent (optional)
* timezone -> task.schedule.timezone (optional, defaults to the server zone)
* body -> task.execution.prompt
*
* `thinking_level` and `goalEnabled`/`goalTokenBudget` are not part of the
* portable format (they are UI-only today); editing them in the file has no
* effect and they remain JSON/UI-only.
*
* Runtime state (lastRunAt, nextRunAt, lastStatus, ...) is never written to
* the markdown file; it continues to live in the project config/state store.
*/
import fs from 'fs';
import os from 'os';
import path from 'path';
import { parseMdFile, getAncestors, findWorktreeRoot } from '../opencode/shared.js';
const LOOP_DIR_NAME = 'loops';
const USER_LOOP_ROOT = () => path.join(os.homedir(), '.agents', LOOP_DIR_NAME);
const asNonEmptyString = (value) => {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
};
/**
* Split a `provider/model` string into its two parts. Splits on the first `/`
* so model ids containing a slash (e.g. `openai/gpt-5`) still resolve.
*/
const splitProviderModel = (value) => {
const raw = asNonEmptyString(value);
if (!raw) {
return null;
}
const separator = raw.indexOf('/');
if (separator <= 0 || separator === raw.length - 1) {
return null;
}
return {
providerId: raw.slice(0, separator).trim(),
modelId: raw.slice(separator + 1).trim(),
};
};
/**
* Parse one loop markdown file into a scheduled-task definition, or return
* null when the file is malformed. Malformed files are skipped with a warning
* and never prevent valid files from loading.
*/
export const parseLoopDefinition = (filePath) => {
let parsed;
try {
parsed = parseMdFile(filePath);
} catch (error) {
console.warn(`[loops] skipped malformed loop file ${filePath}:`, error?.message ?? error);
return null;
}
const frontmatter = parsed.frontmatter && typeof parsed.frontmatter === 'object'
? parsed.frontmatter
: {};
const name = asNonEmptyString(frontmatter.name);
if (!name) {
console.warn(`[loops] skipped ${filePath}: frontmatter "name" is required`);
return null;
}
const cron = asNonEmptyString(frontmatter.schedule);
if (!cron) {
console.warn(`[loops] skipped ${filePath}: frontmatter "schedule" (cron expression) is required`);
return null;
}
const prompt = asNonEmptyString(parsed.body);
if (!prompt) {
console.warn(`[loops] skipped ${filePath}: markdown body (the execution prompt) is required`);
return null;
}
const providerModel = splitProviderModel(frontmatter.model);
if (!providerModel) {
console.warn(`[loops] skipped ${filePath}: frontmatter "model" must be "provider/model"`);
return null;
}
const timezone = asNonEmptyString(frontmatter.timezone);
const agent = asNonEmptyString(frontmatter.agent);
return {
name,
enabled: typeof frontmatter.enabled === 'boolean' ? frontmatter.enabled : true,
schedule: {
kind: 'cron',
cron,
...(timezone ? { timezone } : {}),
},
execution: {
prompt,
providerID: providerModel.providerId,
modelID: providerModel.modelId,
...(agent ? { agent } : {}),
},
};
};
const walkLoopMdFiles = (rootDir) => {
if (!rootDir || !fs.existsSync(rootDir)) {
return [];
}
try {
return fs.readdirSync(rootDir, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
.map((entry) => path.join(rootDir, entry.name))
.sort();
} catch {
return [];
}
};
/**
* Discover loop files for a project: `~/.agents/loops/*.md` (user scope) plus
* `.agents/loops/*.md` in every ancestor of the project path up to the
* worktree root (project scope).
*/
export const discoverLoopFiles = (projectPath) => {
const files = [];
for (const filePath of walkLoopMdFiles(USER_LOOP_ROOT())) {
files.push({ filePath, scope: 'user' });
}
if (projectPath) {
const worktreeRoot = findWorktreeRoot(projectPath) || path.resolve(projectPath);
for (const ancestor of getAncestors(projectPath, worktreeRoot)) {
const root = path.join(ancestor, '.agents', LOOP_DIR_NAME);
for (const filePath of walkLoopMdFiles(root)) {
files.push({ filePath, scope: 'project' });
}
}
}
return files;
};
/**
* Discover and parse all loops for a project. Project-scope loops shadow
* user-scope loops with the same name; among project files the nearest
* ancestor wins. Malformed files are skipped with a warning.
*/
export const discoverLoops = (projectPath) => {
const byName = new Map();
for (const { filePath, scope } of discoverLoopFiles(projectPath)) {
const definition = parseLoopDefinition(filePath);
if (!definition) {
continue;
}
const existing = byName.get(definition.name);
if (existing && (existing.scope === 'project' || scope === 'user')) {
continue;
}
byName.set(definition.name, { scope, filePath, definition });
}
return [...byName.values()];
};