From 2fcfe511a5336fab4c6003ee5c47e25938c98534 Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Wed, 5 Aug 2026 14:11:28 +0300 Subject: [PATCH] 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:: 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 --- .../web/server/lib/projects/project-config.js | 101 ++++++ .../lib/projects/project-config.test.js | 159 ++++++++- .../lib/scheduled-tasks/DOCUMENTATION.md | 66 +++- .../web/server/lib/scheduled-tasks/loops.js | 189 ++++++++++ .../server/lib/scheduled-tasks/loops.test.js | 335 ++++++++++++++++++ .../web/server/lib/scheduled-tasks/runtime.js | 14 +- 6 files changed, 861 insertions(+), 3 deletions(-) create mode 100644 packages/web/server/lib/scheduled-tasks/loops.js create mode 100644 packages/web/server/lib/scheduled-tasks/loops.test.js diff --git a/packages/web/server/lib/projects/project-config.js b/packages/web/server/lib/projects/project-config.js index ed8d98b6..453d487f 100644 --- a/packages/web/server/lib/projects/project-config.js +++ b/packages/web/server/lib/projects/project-config.js @@ -313,6 +313,11 @@ const normalizeTaskForStorage = (value, options) => { const schedule = normalizeSchedule(value.schedule, existingTask?.schedule); const execution = normalizeExecution(value.execution); + // Loop provenance: absolute path of the `.agents/loops/*.md` file driving + // this task, when any. Preserved on every write so the scheduler can detect + // removed loop files across restarts. Unknown to the UI model. + const loopFile = asNonEmptyString(value.loopFile) ?? asNonEmptyString(existingTask?.loopFile); + const nowMs = Math.max(0, Math.round(now)); const baseState = normalizeState(value.state, existingTask?.state); const state = { @@ -328,6 +333,7 @@ const normalizeTaskForStorage = (value, options) => { schedule, execution, state, + ...(loopFile ? { loopFile } : {}), }; }; @@ -559,11 +565,106 @@ export const createProjectConfigRuntime = (deps) => { }); }; + /** + * Reconcile discovered `.agents/loops` definitions with the persisted JSON + * task list. + * + * Rules (documented in scheduled-tasks/DOCUMENTATION.md): + * - 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 its id and runtime state are preserved (markdown + * wins on conflict). + * - A task whose loopFile no longer matches any discovered loop file is + * unscheduled (removed). JSON-configured tasks (no loopFile) are never + * removed. + * - Loops with no matching task are created under a deterministic + * `loop::` id, so runtime state survives restarts. + * - Malformed definitions are skipped with a warning and never block valid + * loops; the scheduler passes only parsed definitions, and normalization + * failures here are isolated per loop. + */ + const reconcileLoopTasks = async (projectID, loops) => { + return withProjectWriteLock(projectID, async () => { + const now = Date.now(); + const current = await readProjectConfigFromDisk(projectID); + const tasks = current.scheduledTasks; + + const activeLoopFilePaths = new Set(); + const pendingLoops = new Map(); + for (const loop of loops) { + if (!loop || !loop.definition || typeof loop.definition !== 'object') { + continue; + } + activeLoopFilePaths.add(loop.filePath); + pendingLoops.set(loop.definition.name, loop); + } + + const nextTasks = []; + for (const task of tasks) { + if (task.loopFile && !activeLoopFilePaths.has(task.loopFile)) { + // The driving loop file was removed (or renamed) — unschedule. + continue; + } + + const loop = pendingLoops.get(task.name); + if (loop) { + try { + const adopted = normalizeTaskForStorage( + { ...task, ...loop.definition, loopFile: loop.filePath }, + { + now, + createId: taskIDFactory, + existingTask: task, + allowCreate: false, + refreshUpdatedAt: false, + }, + ); + nextTasks.push(adopted); + pendingLoops.delete(loop.definition.name); + } catch (error) { + console.warn(`[scheduled-tasks] skipped loop ${loop.filePath} for task "${task.name}":`, error?.message ?? error); + nextTasks.push(task); + } + continue; + } + + nextTasks.push(task); + } + + for (const loop of pendingLoops.values()) { + try { + const id = `loop:${loop.scope}:${loop.definition.name}`; + const created = normalizeTaskForStorage( + { id, ...loop.definition, loopFile: loop.filePath }, + { + now, + createId: taskIDFactory, + existingTask: null, + allowCreate: true, + refreshUpdatedAt: false, + }, + ); + nextTasks.push(created); + } catch (error) { + console.warn(`[scheduled-tasks] skipped loop ${loop.filePath}:`, error?.message ?? error); + } + } + + await writeProjectConfigToDisk(projectID, { + version: PROJECT_CONFIG_VERSION, + scheduledTasks: nextTasks, + }); + + return nextTasks; + }); + }; + return { listScheduledTasks, upsertScheduledTask, deleteScheduledTask, updateScheduledTaskState, + reconcileLoopTasks, resolveProjectConfigPath, }; }; diff --git a/packages/web/server/lib/projects/project-config.test.js b/packages/web/server/lib/projects/project-config.test.js index ef7b5eb4..38122c3f 100644 --- a/packages/web/server/lib/projects/project-config.test.js +++ b/packages/web/server/lib/projects/project-config.test.js @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import os from 'os'; import path from 'path'; import { mkdtemp, rm, readFile, writeFile } from 'fs/promises'; @@ -173,3 +173,160 @@ describe('project-config runtime', () => { } }); }); + +describe('project-config loop reconciliation', () => { + const loop = (name, overrides = {}) => ({ + scope: 'project', + filePath: `/repo/.agents/loops/${name}.md`, + definition: { + name, + enabled: true, + schedule: { kind: 'cron', cron: '0 9 * * *', timezone: 'UTC' }, + execution: { + prompt: `Loop prompt for ${name}`, + providerID: 'openai', + modelID: 'gpt-4.1', + }, + ...overrides, + }, + }); + + it('creates tasks for discovered loops with deterministic ids', async () => { + const { runtime, cleanup } = await createRuntime(); + try { + const tasks = await runtime.reconcileLoopTasks('project-test', [ + loop('daily-digest'), + loop('weekly-report'), + ]); + + expect(tasks).toHaveLength(2); + const digest = tasks.find((task) => task.name === 'daily-digest'); + expect(digest.id).toBe('loop:project:daily-digest'); + expect(digest.schedule.cron).toBe('0 9 * * *'); + expect(digest.execution.providerID).toBe('openai'); + expect(digest.loopFile).toBe('/repo/.agents/loops/daily-digest.md'); + + const reloaded = await runtime.listScheduledTasks('project-test'); + expect(reloaded).toHaveLength(2); + expect(reloaded[0].state.createdAt).toBeGreaterThan(0); + } finally { + await cleanup(); + } + }); + + it('adopts an existing task by name, preserving id and state, and persists state across reconciles', async () => { + const { runtime, cleanup } = await createRuntime(); + try { + const created = await runtime.upsertScheduledTask('project-test', { + name: 'daily-digest', + enabled: true, + schedule: { kind: 'daily', time: '09:30', timezone: 'UTC' }, + execution: { prompt: 'JSON prompt', providerID: 'openai', modelID: 'gpt-4.1' }, + }); + + const first = await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]); + const adopted = first.find((task) => task.id === created.task.id); + expect(adopted).toBeDefined(); + expect(adopted.id).toBe(created.task.id); + expect(adopted.name).toBe('daily-digest'); + expect(adopted.schedule.kind).toBe('cron'); + expect(adopted.schedule.cron).toBe('0 9 * * *'); + expect(adopted.execution.prompt).toBe('Loop prompt for daily-digest'); + expect(adopted.loopFile).toBe('/repo/.agents/loops/daily-digest.md'); + + const state = adopted.state; + await runtime.updateScheduledTaskState('project-test', adopted.id, { + nextRunAt: 123456, + lastRunAt: 111, + lastStatus: 'success', + }); + + const second = await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]); + const again = second.find((task) => task.id === created.task.id); + expect(again.id).toBe(created.task.id); + expect(again.state.nextRunAt).toBe(123456); + expect(again.state.lastRunAt).toBe(111); + expect(again.state.lastStatus).toBe('success'); + expect(again.loopFile).toBe('/repo/.agents/loops/daily-digest.md'); + } finally { + await cleanup(); + } + }); + + it('unschedules a loop-sourced task when its file is removed', async () => { + const { runtime, cleanup } = await createRuntime(); + try { + await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]); + const tasks = await runtime.reconcileLoopTasks('project-test', []); + + expect(tasks).toHaveLength(0); + expect(await runtime.listScheduledTasks('project-test')).toHaveLength(0); + } finally { + await cleanup(); + } + }); + + it('leaves JSON-configured tasks untouched when no loop matches', async () => { + const { runtime, cleanup } = await createRuntime(); + try { + const created = await runtime.upsertScheduledTask('project-test', { + name: 'json-only', + enabled: true, + schedule: { kind: 'daily', time: '08:00', timezone: 'UTC' }, + execution: { prompt: 'JSON prompt', providerID: 'openai', modelID: 'gpt-4.1' }, + }); + + const tasks = await runtime.reconcileLoopTasks('project-test', [loop('loop-only')]); + + expect(tasks).toHaveLength(2); + expect(tasks.find((task) => task.id === created.task.id)).toBeDefined(); + expect(tasks.find((task) => task.name === 'loop-only')).toBeDefined(); + } finally { + await cleanup(); + } + }); + + it('does not remove a JSON task that merely shares a loop name after the loop is gone... keeps it when never adopted', async () => { + // A JSON task that was never driven by a loop file (no loopFile marker) + // must survive reconciles even when a loop with the same name existed + // only in a previous reconcile round — but once a loop adopted it, the + // file is authoritative and removing the file unschedules the task. + const { runtime, cleanup } = await createRuntime(); + try { + const created = await runtime.upsertScheduledTask('project-test', { + name: 'daily-digest', + enabled: true, + schedule: { kind: 'daily', time: '09:30', timezone: 'UTC' }, + execution: { prompt: 'JSON prompt', providerID: 'openai', modelID: 'gpt-4.1' }, + }); + + // First reconcile adopts the task (loopFile marker set). + await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]); + // Loop file removed -> task unscheduled. + const afterRemoval = await runtime.reconcileLoopTasks('project-test', []); + expect(afterRemoval.find((task) => task.id === created.task.id)).toBeUndefined(); + } finally { + await cleanup(); + } + }); + + it('skips invalid loop definitions without blocking valid ones', async () => { + const { runtime, cleanup } = await createRuntime(); + try { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + try { + const tasks = await runtime.reconcileLoopTasks('project-test', [ + loop('bad-loop', { schedule: { kind: 'cron', cron: 'not a cron', timezone: 'UTC' } }), + loop('good-loop'), + ]); + + expect(tasks.map((task) => task.name)).toEqual(['good-loop']); + expect(warn).toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + } finally { + await cleanup(); + } + }); +}); diff --git a/packages/web/server/lib/scheduled-tasks/DOCUMENTATION.md b/packages/web/server/lib/scheduled-tasks/DOCUMENTATION.md index 2fc61770..16206a82 100644 --- a/packages/web/server/lib/scheduled-tasks/DOCUMENTATION.md +++ b/packages/web/server/lib/scheduled-tasks/DOCUMENTATION.md @@ -5,7 +5,8 @@ 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`. -- Runtime orchestration and execution is owned by this module. +- 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 @@ -17,11 +18,74 @@ Server-owned scheduled task runtime and routes for OpenChamber-only automation. - 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::` 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)` diff --git a/packages/web/server/lib/scheduled-tasks/loops.js b/packages/web/server/lib/scheduled-tasks/loops.js new file mode 100644 index 00000000..2dbcf4c2 --- /dev/null +++ b/packages/web/server/lib/scheduled-tasks/loops.js @@ -0,0 +1,189 @@ +/** + * 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()]; +}; diff --git a/packages/web/server/lib/scheduled-tasks/loops.test.js b/packages/web/server/lib/scheduled-tasks/loops.test.js new file mode 100644 index 00000000..ac204392 --- /dev/null +++ b/packages/web/server/lib/scheduled-tasks/loops.test.js @@ -0,0 +1,335 @@ +import { describe, expect, it, vi } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { mkdtemp, rm, writeFile, mkdir } from 'fs/promises'; +import { parseLoopDefinition, discoverLoops, discoverLoopFiles } from './loops.js'; + +const createProject = async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'oc-loops-')); + const projectPath = path.join(tempRoot, 'repo'); + await mkdir(projectPath, { recursive: true }); + await mkdir(path.join(projectPath, '.git'), { recursive: true }); + return { + projectPath, + cleanup: async () => { + await rm(tempRoot, { recursive: true, force: true }); + }, + }; +}; + +const writeLoop = async (projectPath, fileName, content) => { + const dir = path.join(projectPath, '.agents', 'loops'); + await mkdir(dir, { recursive: true }); + await writeFile(path.join(dir, fileName), content, 'utf8'); +}; + +describe('parseLoopDefinition', () => { + it('maps frontmatter and body to the scheduled-task definition shape', async () => { + const { projectPath, cleanup } = await createProject(); + try { + await writeLoop(projectPath, 'digest.md', `--- +name: daily-digest +schedule: "0 9 * * *" +enabled: true +model: anthropic/claude-sonnet-4-5 +agent: plan +timezone: Europe/Kyiv +--- +Summarize repository changes since yesterday. +`); + + const definition = parseLoopDefinition(path.join(projectPath, '.agents', 'loops', 'digest.md')); + + expect(definition).toEqual({ + name: 'daily-digest', + enabled: true, + schedule: { kind: 'cron', cron: '0 9 * * *', timezone: 'Europe/Kyiv' }, + execution: { + prompt: 'Summarize repository changes since yesterday.', + providerID: 'anthropic', + modelID: 'claude-sonnet-4-5', + agent: 'plan', + }, + }); + } finally { + await cleanup(); + } + }); + + it('splits model ids containing a slash on the first separator', async () => { + const { projectPath, cleanup } = await createProject(); + try { + const filePath = path.join(projectPath, 'loop.md'); + await writeFile(filePath, `--- +name: nested-model +schedule: "0 8 * * 1" +model: openai/gpt-5 +--- +Run weekly checks. +`, 'utf8'); + + const definition = parseLoopDefinition(filePath); + + expect(definition.execution.providerID).toBe('openai'); + expect(definition.execution.modelID).toBe('gpt-5'); + expect(definition.enabled).toBe(true); + } finally { + await cleanup(); + } + }); + + it('defaults enabled to true and omits optional fields', async () => { + const { projectPath, cleanup } = await createProject(); + try { + const filePath = path.join(projectPath, 'loop.md'); + await writeFile(filePath, `--- +name: minimal +schedule: "*/30 * * * *" +model: openai/gpt-5 +enabled: false +--- +Run every half hour. +`, 'utf8'); + + const definition = parseLoopDefinition(filePath); + + expect(definition.enabled).toBe(false); + expect(definition.schedule).toEqual({ kind: 'cron', cron: '*/30 * * * *' }); + expect(definition.execution.agent).toBeUndefined(); + } finally { + await cleanup(); + } + }); + + it('returns null for files missing required frontmatter fields', async () => { + const { projectPath, cleanup } = await createProject(); + try { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + try { + const noName = path.join(projectPath, 'noname.md'); + await writeFile(noName, `--- +schedule: "0 9 * * *" +model: openai/gpt-5 +--- +Prompt only. +`, 'utf8'); + expect(parseLoopDefinition(noName)).toBeNull(); + + const noSchedule = path.join(projectPath, 'noschedule.md'); + await writeFile(noSchedule, `--- +name: no-schedule +model: openai/gpt-5 +--- +Prompt only. +`, 'utf8'); + expect(parseLoopDefinition(noSchedule)).toBeNull(); + + const noModel = path.join(projectPath, 'nomodel.md'); + await writeFile(noModel, `--- +name: no-model +schedule: "0 9 * * *" +--- +Prompt only. +`, 'utf8'); + expect(parseLoopDefinition(noModel)).toBeNull(); + + const malformed = path.join(projectPath, 'malformed.md'); + await writeFile(malformed, 'not a markdown frontmatter file at all', 'utf8'); + expect(parseLoopDefinition(malformed)).toBeNull(); + } finally { + warn.mockRestore(); + } + } finally { + await cleanup(); + } + }); + + it('treats a missing body as an invalid loop', async () => { + const { projectPath, cleanup } = await createProject(); + try { + const filePath = path.join(projectPath, 'empty-body.md'); + await writeFile(filePath, `--- +name: empty-body +schedule: "0 9 * * *" +model: openai/gpt-5 +--- +`, 'utf8'); + + expect(parseLoopDefinition(filePath)).toBeNull(); + } finally { + await cleanup(); + } + }); +}); + +describe('discoverLoops', () => { + it('discovers project loops and parses them', async () => { + const { projectPath, cleanup } = await createProject(); + try { + await writeLoop(projectPath, 'digest.md', `--- +name: daily-digest +schedule: "0 9 * * *" +model: openai/gpt-5 +--- +Summarize. +`); + + const loops = discoverLoops(projectPath); + + expect(loops).toHaveLength(1); + expect(loops[0].scope).toBe('project'); + expect(loops[0].definition.name).toBe('daily-digest'); + expect(loops[0].filePath.endsWith(path.join('.agents', 'loops', 'digest.md'))).toBe(true); + } finally { + await cleanup(); + } + }); + + it('scans ancestor directories up to the worktree root', async () => { + const { projectPath, cleanup } = await createProject(); + try { + // Worktree root contains the loop; the project directory is nested. + const nested = path.join(projectPath, 'src', 'nested'); + await mkdir(nested, { recursive: true }); + await writeLoop(projectPath, 'root-loop.md', `--- +name: root-loop +schedule: "0 9 * * *" +model: openai/gpt-5 +--- +From the root. +`); + + const loops = discoverLoops(nested); + + expect(loops.map((loop) => loop.definition.name)).toEqual(['root-loop']); + expect(loops[0].scope).toBe('project'); + } finally { + await cleanup(); + } + }); + + it('discovers user-scope loops from ~/.agents/loops', async () => { + const { projectPath, cleanup } = await createProject(); + const home = await mkdtemp(path.join(os.tmpdir(), 'oc-loops-home-')); + const userDir = path.join(home, '.agents', 'loops'); + await mkdir(userDir, { recursive: true }); + await writeFile(path.join(userDir, 'user-loop.md'), `--- +name: user-loop +schedule: "0 7 * * *" +model: openai/gpt-5 +--- +User scope. +`, 'utf8'); + const originalHome = os.homedir; + vi.spyOn(os, 'homedir').mockReturnValue(home); + + try { + const loops = discoverLoops(projectPath); + + expect(loops.map((loop) => loop.definition.name)).toEqual(['user-loop']); + expect(loops[0].scope).toBe('user'); + } finally { + os.homedir = originalHome; + await rm(home, { recursive: true, force: true }); + await cleanup(); + } + }); + + it('lets project scope shadow user scope on name collision', async () => { + const { projectPath, cleanup } = await createProject(); + const home = await mkdtemp(path.join(os.tmpdir(), 'oc-loops-home-')); + const userDir = path.join(home, '.agents', 'loops'); + await mkdir(userDir, { recursive: true }); + await writeFile(path.join(userDir, 'same-name.md'), `--- +name: shared +schedule: "0 7 * * *" +model: openai/gpt-5 +--- +User version. +`, 'utf8'); + await writeLoop(projectPath, 'same-name.md', `--- +name: shared +schedule: "0 8 * * *" +model: anthropic/claude-sonnet-4-5 +--- +Project version. +`); + const originalHome = os.homedir; + vi.spyOn(os, 'homedir').mockReturnValue(home); + + try { + const loops = discoverLoops(projectPath); + + expect(loops).toHaveLength(1); + expect(loops[0].scope).toBe('project'); + expect(loops[0].definition.execution.providerID).toBe('anthropic'); + expect(loops[0].definition.schedule.cron).toBe('0 8 * * *'); + } finally { + os.homedir = originalHome; + await rm(home, { recursive: true, force: true }); + await cleanup(); + } + }); + + it('skips malformed files without blocking valid ones', async () => { + const { projectPath, cleanup } = await createProject(); + try { + await writeLoop(projectPath, 'bad.md', `--- +name: bad +schedule: "0 9 * * *" +--- +No model. +`); + await writeLoop(projectPath, 'good.md', `--- +name: good +schedule: "0 9 * * *" +model: openai/gpt-5 +--- +Valid. +`); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + try { + const loops = discoverLoops(projectPath); + + expect(loops.map((loop) => loop.definition.name)).toEqual(['good']); + expect(warn).toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + } finally { + await cleanup(); + } + }); + + it('returns an empty list when nothing exists', async () => { + const { projectPath, cleanup } = await createProject(); + try { + expect(discoverLoops(projectPath)).toEqual([]); + } finally { + await cleanup(); + } + }); + + it('lists raw loop files per scope without parsing', async () => { + const { projectPath, cleanup } = await createProject(); + try { + await writeLoop(projectPath, 'one.md', `--- +name: one +schedule: "0 9 * * *" +model: openai/gpt-5 +--- +One. +`); + await writeFile(path.join(projectPath, 'not-a-loop.txt'), 'ignore me', 'utf8'); + + const files = discoverLoopFiles(projectPath); + + expect(files).toHaveLength(1); + expect(files[0].scope).toBe('project'); + expect(files[0].filePath.endsWith(path.join('.agents', 'loops', 'one.md'))).toBe(true); + } finally { + await cleanup(); + } + }); +}); diff --git a/packages/web/server/lib/scheduled-tasks/runtime.js b/packages/web/server/lib/scheduled-tasks/runtime.js index 5b1cffc7..62015b95 100644 --- a/packages/web/server/lib/scheduled-tasks/runtime.js +++ b/packages/web/server/lib/scheduled-tasks/runtime.js @@ -3,6 +3,7 @@ import { DateTime } from 'luxon'; import parser from 'cron-parser'; import { expandSnippets } from '../opencode/snippets.js'; import { buildGoalIntroText, createSessionGoal } from '../session-goal/create.js'; +import { discoverLoops } from './loops.js'; const DEFAULT_GLOBAL_CONCURRENCY = 4; const DEFAULT_PROJECT_CONCURRENCY = 2; @@ -382,8 +383,19 @@ export const createScheduledTasksRuntime = (deps) => { const syncProject = async (projectID) => { await ensureProjectPath(projectID); + const projectPath = projectPathByID.get(projectID) || null; + + let tasks; + if (projectPath) { + // Reconcile `.agents/loops` definitions with the persisted task list: + // loop files are authoritative while present, removed files unschedule + // their task, and runtime state is preserved (see loops.js). + const loops = await discoverLoops(projectPath); + tasks = await projectConfigRuntime.reconcileLoopTasks(projectID, loops); + } else { + tasks = await projectConfigRuntime.listScheduledTasks(projectID); + } - const tasks = await projectConfigRuntime.listScheduledTasks(projectID); setProjectTasks(projectID, tasks); for (const task of tasks) {