From 2fcfe511a5336fab4c6003ee5c47e25938c98534 Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Wed, 5 Aug 2026 14:11:28 +0300 Subject: [PATCH 1/5] 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) { From 359225d36374e3fe3810821aacfc4e18ca1ff424 Mon Sep 17 00:00:00 2001 From: makeittech Date: Thu, 6 Aug 2026 09:28:31 +0300 Subject: [PATCH 2/5] fix(tasks): harden loop reconciliation against renames and malformed files 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. --- .../web/server/lib/projects/project-config.js | 55 +++++-- .../lib/projects/project-config.test.js | 142 ++++++++++++++++++ .../lib/scheduled-tasks/DOCUMENTATION.md | 31 ++-- .../web/server/lib/scheduled-tasks/loops.js | 21 ++- .../server/lib/scheduled-tasks/loops.test.js | 37 ++++- .../web/server/lib/scheduled-tasks/service.js | 11 ++ .../lib/scheduled-tasks/service.test.js | 65 ++++++++ 7 files changed, 334 insertions(+), 28 deletions(-) create mode 100644 packages/web/server/lib/scheduled-tasks/service.test.js diff --git a/packages/web/server/lib/projects/project-config.js b/packages/web/server/lib/projects/project-config.js index 453d487f..54166695 100644 --- a/packages/web/server/lib/projects/project-config.js +++ b/packages/web/server/lib/projects/project-config.js @@ -570,18 +570,27 @@ export const createProjectConfigRuntime = (deps) => { * 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). + * - For loop-owned tasks (carrying the `loopFile` marker) identity is the + * LOOP FILE PATH: a loop takes its task over regardless of the task's + * current name, so renaming the loop (`name` field or a UI edit) renames + * the task in place instead of leaving a stale duplicate behind. + * - A loop whose name matches a JSON task (no `loopFile`) 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). + * Execution fields the file format does not define (goalEnabled, + * goalTokenBudget, permissionAutoAccept, variant) are preserved. * - A task whose loopFile no longer matches any discovered loop file is * unscheduled (removed). JSON-configured tasks (no loopFile) are never * removed. + * - A task whose loop file still exists but is currently unparseable is + * KEPT with its last good definition: only a genuinely removed file + * unschedules a task, so transiently malformed files (mid-edit, bad + * merge) never delete tasks or their runtime state. * - 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. + * loops; the scheduler passes them as `definition: null` entries, and + * normalization failures here are isolated per loop. */ const reconcileLoopTasks = async (projectID, loops) => { return withProjectWriteLock(projectID, async () => { @@ -591,14 +600,19 @@ export const createProjectConfigRuntime = (deps) => { const activeLoopFilePaths = new Set(); const pendingLoops = new Map(); + const loopsByPath = new Map(); for (const loop of loops) { - if (!loop || !loop.definition || typeof loop.definition !== 'object') { + if (!loop || typeof loop.filePath !== 'string' || !loop.filePath) { continue; } activeLoopFilePaths.add(loop.filePath); - pendingLoops.set(loop.definition.name, loop); + if (loop.definition && typeof loop.definition === 'object') { + pendingLoops.set(loop.definition.name, loop); + loopsByPath.set(loop.filePath, loop); + } } + const consumedLoopPaths = new Set(); const nextTasks = []; for (const task of tasks) { if (task.loopFile && !activeLoopFilePaths.has(task.loopFile)) { @@ -606,11 +620,22 @@ export const createProjectConfigRuntime = (deps) => { continue; } - const loop = pendingLoops.get(task.name); + // Loop-owned tasks adopt by file path (covers renames of the `name` + // field); JSON tasks adopt by name. + const loop = task.loopFile + ? loopsByPath.get(task.loopFile) || null + : pendingLoops.get(task.name) || null; if (loop) { try { const adopted = normalizeTaskForStorage( - { ...task, ...loop.definition, loopFile: loop.filePath }, + { + ...task, + ...loop.definition, + // File-defined execution fields win; UI-only fields the file + // format does not define are preserved from the task. + execution: { ...task.execution, ...loop.definition.execution }, + loopFile: loop.filePath, + }, { now, createId: taskIDFactory, @@ -621,6 +646,10 @@ export const createProjectConfigRuntime = (deps) => { ); nextTasks.push(adopted); pendingLoops.delete(loop.definition.name); + if (task.loopFile) { + consumedLoopPaths.add(task.loopFile); + loopsByPath.delete(task.loopFile); + } } catch (error) { console.warn(`[scheduled-tasks] skipped loop ${loop.filePath} for task "${task.name}":`, error?.message ?? error); nextTasks.push(task); @@ -628,6 +657,12 @@ export const createProjectConfigRuntime = (deps) => { continue; } + if (task.loopFile && consumedLoopPaths.has(task.loopFile)) { + // Orphan duplicate: another task already adopted this loop file + // (left over from a rename) — unschedule it. + continue; + } + nextTasks.push(task); } diff --git a/packages/web/server/lib/projects/project-config.test.js b/packages/web/server/lib/projects/project-config.test.js index 38122c3f..408866c8 100644 --- a/packages/web/server/lib/projects/project-config.test.js +++ b/packages/web/server/lib/projects/project-config.test.js @@ -329,4 +329,146 @@ describe('project-config loop reconciliation', () => { await cleanup(); } }); + + it('renames a loop-sourced task in place when the loop name changes but the file stays', async () => { + // Identity for loop-owned tasks is the loop file path: changing the `name` + // field (or renaming via the UI) must not leave a stale duplicate running. + const { runtime, cleanup } = await createRuntime(); + try { + const first = await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]); + const original = first.find((task) => task.name === 'daily-digest'); + + const renamed = await runtime.reconcileLoopTasks('project-test', [{ + scope: 'project', + filePath: '/repo/.agents/loops/daily-digest.md', + definition: { + name: 'digest', + enabled: true, + schedule: { kind: 'cron', cron: '0 9 * * *', timezone: 'UTC' }, + execution: { prompt: 'Loop prompt for digest', providerID: 'openai', modelID: 'gpt-4.1' }, + }, + }]); + + expect(renamed).toHaveLength(1); + const adopted = renamed[0]; + expect(adopted.id).toBe(original.id); + expect(adopted.name).toBe('digest'); + expect(adopted.loopFile).toBe('/repo/.agents/loops/daily-digest.md'); + expect(adopted.execution.prompt).toBe('Loop prompt for digest'); + } finally { + await cleanup(); + } + }); + + it('reverts a UI rename of a loop task back to the loop name on reconcile', async () => { + const { runtime, cleanup } = await createRuntime(); + try { + await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]); + const created = (await runtime.listScheduledTasks('project-test'))[0]; + + // The UI editor renamed the task; loopFile survives the write. + await runtime.upsertScheduledTask('project-test', { + id: created.id, + name: 'renamed-by-ui', + enabled: true, + schedule: { kind: 'daily', time: '09:30', timezone: 'UTC' }, + execution: { prompt: 'UI prompt', providerID: 'openai', modelID: 'gpt-4.1' }, + }); + + const after = await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]); + expect(after).toHaveLength(1); + expect(after[0].id).toBe(created.id); + expect(after[0].name).toBe('daily-digest'); + expect(after[0].execution.prompt).toBe('Loop prompt for daily-digest'); + } finally { + await cleanup(); + } + }); + + it('keeps a loop-sourced task while its file exists but is currently unparseable', async () => { + // A transiently malformed file (mid-edit, bad merge) must not delete the + // task or its runtime state — only a genuinely removed file unschedules. + const { runtime, cleanup } = await createRuntime(); + try { + const first = await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]); + const original = first[0]; + await runtime.updateScheduledTaskState('project-test', original.id, { + nextRunAt: 123456, + lastRunAt: 111, + lastStatus: 'success', + }); + + const after = await runtime.reconcileLoopTasks('project-test', [{ + scope: 'project', + filePath: '/repo/.agents/loops/daily-digest.md', + definition: null, + }]); + + expect(after).toHaveLength(1); + expect(after[0].id).toBe(original.id); + expect(after[0].name).toBe('daily-digest'); + expect(after[0].loopFile).toBe('/repo/.agents/loops/daily-digest.md'); + expect(after[0].schedule.cron).toBe('0 9 * * *'); + expect(after[0].state.nextRunAt).toBe(123456); + expect(after[0].state.lastStatus).toBe('success'); + } finally { + await cleanup(); + } + }); + + it('unschedules orphan duplicates of the same loop file', async () => { + // Zombie cleanup: two tasks driving one file (e.g. left over from a + // rename under the old name-identity rules) — the later one is removed. + const { runtime, cleanup } = await createRuntime(); + try { + const first = await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]); + const original = first[0]; + await runtime.upsertScheduledTask('project-test', { + id: 'zombie-copy', + name: 'daily-digest-copy', + enabled: true, + loopFile: '/repo/.agents/loops/daily-digest.md', + schedule: { kind: 'daily', time: '09:30', timezone: 'UTC' }, + execution: { prompt: 'Stale copy', providerID: 'openai', modelID: 'gpt-4.1' }, + }); + + const after = await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]); + + expect(after).toHaveLength(1); + expect(after[0].id).toBe(original.id); + expect(after.find((task) => task.id === 'zombie-copy')).toBeUndefined(); + } finally { + await cleanup(); + } + }); + + it('preserves UI-only execution fields when adopting a JSON task', 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', + variant: 'fast', + goalEnabled: true, + goalTokenBudget: 20000, + permissionAutoAccept: true, + }, + }); + + const adopted = await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]); + const task = adopted.find((entry) => entry.id === created.task.id); + expect(task.execution.prompt).toBe('Loop prompt for daily-digest'); + expect(task.execution.variant).toBe('fast'); + expect(task.execution.goalEnabled).toBe(true); + expect(task.execution.goalTokenBudget).toBe(20000); + expect(task.execution.permissionAutoAccept).toBe(true); + } finally { + await cleanup(); + } + }); }); diff --git a/packages/web/server/lib/scheduled-tasks/DOCUMENTATION.md b/packages/web/server/lib/scheduled-tasks/DOCUMENTATION.md index 16206a82..5bdba849 100644 --- a/packages/web/server/lib/scheduled-tasks/DOCUMENTATION.md +++ b/packages/web/server/lib/scheduled-tasks/DOCUMENTATION.md @@ -50,7 +50,7 @@ Field mapping (model: `packages/ui/src/lib/scheduledTasksApi.ts`): |---|---| | `name` | `name` (required) | | `schedule` | `schedule.kind: "cron"` + `schedule.cron` (required, cron-only in the portable format) | -| `enabled` | `enabled` (default `true`) | +| `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) | @@ -67,24 +67,37 @@ the project config state store. `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). +- **Identity.** For loop-owned tasks (carrying the `loopFile` marker) identity + is the loop file path: a loop takes its task over regardless of the task's + current name, so renaming the loop (the `name` field, or a UI rename) renames + the task in place instead of leaving a stale duplicate behind. A loop whose + name matches a JSON task (no `loopFile`) takes that task over instead: its + schedule/execution/enabled are overwritten from the file while the task's + `id` and runtime `state` are 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 `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. + 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::` id so runtime state survives restarts. + `loop::` 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 skipped with a warning and never block valid loops in the - same or other scopes. + unreadable) are reported to the scheduler as `definition: null` entries 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`). Use `enabled: false` in the file to disable. + definition (including `enabled`). Use `enabled: false` in 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) diff --git a/packages/web/server/lib/scheduled-tasks/loops.js b/packages/web/server/lib/scheduled-tasks/loops.js index 2dbcf4c2..1fd71be7 100644 --- a/packages/web/server/lib/scheduled-tasks/loops.js +++ b/packages/web/server/lib/scheduled-tasks/loops.js @@ -21,7 +21,9 @@ * 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) + * enabled -> task.enabled (default false — loops only run when the file + * explicitly enables them, so discovery never auto-executes + * repository content) * model -> split into task.execution.providerID / task.execution.modelID * agent -> task.execution.agent (optional) * timezone -> task.schedule.timezone (optional, defaults to the server zone) @@ -116,7 +118,7 @@ export const parseLoopDefinition = (filePath) => { return { name, - enabled: typeof frontmatter.enabled === 'boolean' ? frontmatter.enabled : true, + enabled: typeof frontmatter.enabled === 'boolean' ? frontmatter.enabled : false, schedule: { kind: 'cron', cron, @@ -170,13 +172,21 @@ export const discoverLoopFiles = (projectPath) => { /** * 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. + * ancestor wins. + * + * Unparseable files are reported as `{ scope, filePath, definition: null }` + * entries instead of being dropped: the scheduler must distinguish "file is + * gone" (unschedule its task) from "file exists but is currently malformed" + * (keep its task with the last good definition until the file is fixed). + * Malformed files never block valid ones in the same or other scopes. */ export const discoverLoops = (projectPath) => { const byName = new Map(); + const loops = []; for (const { filePath, scope } of discoverLoopFiles(projectPath)) { const definition = parseLoopDefinition(filePath); if (!definition) { + loops.push({ scope, filePath, definition: null }); continue; } const existing = byName.get(definition.name); @@ -185,5 +195,8 @@ export const discoverLoops = (projectPath) => { } byName.set(definition.name, { scope, filePath, definition }); } - return [...byName.values()]; + for (const entry of byName.values()) { + loops.push(entry); + } + return loops; }; diff --git a/packages/web/server/lib/scheduled-tasks/loops.test.js b/packages/web/server/lib/scheduled-tasks/loops.test.js index ac204392..7281adda 100644 --- a/packages/web/server/lib/scheduled-tasks/loops.test.js +++ b/packages/web/server/lib/scheduled-tasks/loops.test.js @@ -73,13 +73,13 @@ Run weekly checks. expect(definition.execution.providerID).toBe('openai'); expect(definition.execution.modelID).toBe('gpt-5'); - expect(definition.enabled).toBe(true); + expect(definition.enabled).toBe(false); } finally { await cleanup(); } }); - it('defaults enabled to true and omits optional fields', async () => { + it('defaults enabled to false and omits optional fields', async () => { const { projectPath, cleanup } = await createProject(); try { const filePath = path.join(projectPath, 'loop.md'); @@ -87,13 +87,14 @@ Run weekly checks. name: minimal schedule: "*/30 * * * *" model: openai/gpt-5 -enabled: false --- Run every half hour. `, 'utf8'); const definition = parseLoopDefinition(filePath); + // Loops only run when the file explicitly enables them: discovery of + // repository content must never auto-execute scheduled sessions. expect(definition.enabled).toBe(false); expect(definition.schedule).toEqual({ kind: 'cron', cron: '*/30 * * * *' }); expect(definition.execution.agent).toBeUndefined(); @@ -102,6 +103,25 @@ Run every half hour. } }); + it('honors an explicit enabled: true in the frontmatter', async () => { + const { projectPath, cleanup } = await createProject(); + try { + const filePath = path.join(projectPath, 'loop.md'); + await writeFile(filePath, `--- +name: explicit-enabled +schedule: "*/30 * * * *" +model: openai/gpt-5 +enabled: true +--- +Run every half hour. +`, 'utf8'); + + expect(parseLoopDefinition(filePath).enabled).toBe(true); + } finally { + await cleanup(); + } + }); + it('returns null for files missing required frontmatter fields', async () => { const { projectPath, cleanup } = await createProject(); try { @@ -272,7 +292,7 @@ Project version. } }); - it('skips malformed files without blocking valid ones', async () => { + it('reports malformed files as unparsed entries without blocking valid ones', async () => { const { projectPath, cleanup } = await createProject(); try { await writeLoop(projectPath, 'bad.md', `--- @@ -292,7 +312,14 @@ Valid. try { const loops = discoverLoops(projectPath); - expect(loops.map((loop) => loop.definition.name)).toEqual(['good']); + // The malformed file stays visible as a `definition: null` entry so + // the scheduler can keep its task alive while the file is fixed. + const bad = loops.find((loop) => loop.filePath.endsWith(path.join('.agents', 'loops', 'bad.md'))); + expect(bad.definition).toBeNull(); + expect(bad.scope).toBe('project'); + + const good = loops.find((loop) => loop.filePath.endsWith(path.join('.agents', 'loops', 'good.md'))); + expect(good.definition.name).toBe('good'); expect(warn).toHaveBeenCalled(); } finally { warn.mockRestore(); diff --git a/packages/web/server/lib/scheduled-tasks/service.js b/packages/web/server/lib/scheduled-tasks/service.js index 7d44f4e8..33d69f7d 100644 --- a/packages/web/server/lib/scheduled-tasks/service.js +++ b/packages/web/server/lib/scheduled-tasks/service.js @@ -78,6 +78,17 @@ export const createScheduledTaskService = (dependencies) => { await findProjectByID(projectID); const normalizedTaskID = asNonEmptyString(taskID); if (!normalizedTaskID) throw new OpenChamberControlError('taskId is required', 400); + const current = await projectConfigRuntime.listScheduledTasks(projectID); + const existing = current.find((task) => task.id === normalizedTaskID) || null; + if (existing?.loopFile) { + // Loop tasks are owned by their `.agents/loops` markdown file: deleting + // the JSON row would be silently undone by the next reconcile. The file + // itself is the removal surface. + throw new OpenChamberControlError( + 'Loop task is managed by its .agents/loops markdown file; delete the file to remove the task', + 400, + ); + } const result = await projectConfigRuntime.deleteScheduledTask(projectID, normalizedTaskID); if (!result.deleted) throw new OpenChamberControlError('Task not found', 404); await scheduledTasksRuntime.syncProject(projectID); diff --git a/packages/web/server/lib/scheduled-tasks/service.test.js b/packages/web/server/lib/scheduled-tasks/service.test.js new file mode 100644 index 00000000..07d6bf70 --- /dev/null +++ b/packages/web/server/lib/scheduled-tasks/service.test.js @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createScheduledTaskService } from './service.js'; + +const createService = (overrides = {}) => { + const projectConfigRuntime = { + listScheduledTasks: vi.fn(async () => []), + deleteScheduledTask: vi.fn(async () => ({ deleted: true, tasks: [] })), + ...(overrides.projectConfigRuntime || {}), + }; + const scheduledTasksRuntime = { + syncProject: vi.fn(async () => []), + ...(overrides.scheduledTasksRuntime || {}), + }; + const service = createScheduledTaskService({ + readSettingsFromDiskMigrated: async () => ({ + projects: [{ id: 'project-test', path: '/repo' }], + }), + sanitizeProjects: (projects) => projects, + projectConfigRuntime, + scheduledTasksRuntime, + }); + return { service, projectConfigRuntime, scheduledTasksRuntime }; +}; + +const loopTask = { + id: 'loop:project:daily-digest', + name: 'daily-digest', + enabled: true, + loopFile: '/repo/.agents/loops/daily-digest.md', + schedule: { kind: 'cron', cron: '0 9 * * *', timezone: 'UTC' }, + execution: { prompt: 'digest', providerID: 'openai', modelID: 'gpt-4.1' }, +}; + +describe('scheduled-task service remove', () => { + it('rejects deleting a loop-sourced task without touching storage', async () => { + const { service, projectConfigRuntime, scheduledTasksRuntime } = createService({ + projectConfigRuntime: { + listScheduledTasks: vi.fn(async () => [loopTask]), + }, + }); + + await expect(service.remove('project-test', loopTask.id)).rejects.toMatchObject({ + statusCode: 400, + message: expect.stringContaining('delete the file to remove the task'), + }); + expect(projectConfigRuntime.deleteScheduledTask).not.toHaveBeenCalled(); + expect(scheduledTasksRuntime.syncProject).not.toHaveBeenCalled(); + }); + + it('deletes JSON-configured tasks normally', async () => { + const jsonTask = { ...loopTask, id: 'json-task', loopFile: undefined }; + const { service, projectConfigRuntime, scheduledTasksRuntime } = createService({ + projectConfigRuntime: { + listScheduledTasks: vi.fn(async () => [jsonTask]), + deleteScheduledTask: vi.fn(async () => ({ deleted: true, tasks: [] })), + }, + }); + + const tasks = await service.remove('project-test', jsonTask.id); + + expect(projectConfigRuntime.deleteScheduledTask).toHaveBeenCalledWith('project-test', jsonTask.id); + expect(scheduledTasksRuntime.syncProject).toHaveBeenCalled(); + expect(Array.isArray(tasks)).toBe(true); + }); +}); From 59a6c1b70db723431e1a5771f7e6165e7045453f Mon Sep 17 00:00:00 2001 From: makeittech Date: Thu, 6 Aug 2026 09:38:18 +0300 Subject: [PATCH 3/5] fix(tasks): guard loop name length and surface loop ownership in the UI Review follow-up: - Reject loop files whose frontmatter name exceeds MAX_TASK_NAME_LENGTH (80): task names are clamped at storage time, so a raw name longer than the limit could never match the stored task identity. The file is treated as malformed (definition: null) instead of creating an unreachable definition; MAX_TASK_NAME_LENGTH is now exported from project-config.js and shared with loops.js. - Surface loop-sourced tasks in the scheduled-tasks dialog: tasks carrying loopFile show a 'Managed by loop file ' note, and the enable toggle / edit / delete actions are disabled with an explanatory tooltip, since the file remains authoritative and would revert any such change. run-now stays available. New locale keys added to all 11 message files (i18n parity test enforces exact key sets). - ScheduledTask type gains an optional loopFile field (additive, unknown to older clients). --- .../session/ScheduledTasksDialog.tsx | 25 ++++++++++++++--- packages/ui/src/lib/i18n/messages/de.ts | 3 +++ packages/ui/src/lib/i18n/messages/en.ts | 3 +++ packages/ui/src/lib/i18n/messages/es.ts | 3 +++ packages/ui/src/lib/i18n/messages/fr.ts | 3 +++ packages/ui/src/lib/i18n/messages/ja.ts | 3 +++ packages/ui/src/lib/i18n/messages/ko.ts | 3 +++ packages/ui/src/lib/i18n/messages/pl.ts | 3 +++ packages/ui/src/lib/i18n/messages/pt-BR.ts | 3 +++ packages/ui/src/lib/i18n/messages/uk.ts | 3 +++ packages/ui/src/lib/i18n/messages/zh-CN.ts | 3 +++ packages/ui/src/lib/i18n/messages/zh-TW.ts | 3 +++ packages/ui/src/lib/scheduledTasksApi.ts | 3 +++ .../web/server/lib/projects/project-config.js | 2 +- .../lib/scheduled-tasks/DOCUMENTATION.md | 6 +++-- .../web/server/lib/scheduled-tasks/loops.js | 7 +++++ .../server/lib/scheduled-tasks/loops.test.js | 27 +++++++++++++++++++ 17 files changed, 96 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/components/session/ScheduledTasksDialog.tsx b/packages/ui/src/components/session/ScheduledTasksDialog.tsx index c75e677e..6590e67b 100644 --- a/packages/ui/src/components/session/ScheduledTasksDialog.tsx +++ b/packages/ui/src/components/session/ScheduledTasksDialog.tsx @@ -463,6 +463,14 @@ export function ScheduledTasksDialog() {
{formatSchedule(task, t)}
+ {task.loopFile ? ( +
+ {t('sessions.scheduledTasks.dialog.loopFile.note', { file: task.loopFile })} +
+ ) : null}
@@ -525,8 +533,11 @@ export function ScheduledTasksDialog() { className={cn( 'inline-flex cursor-pointer items-center gap-2 typography-micro font-medium', task.enabled ? 'text-foreground' : 'text-muted-foreground', - isBusy && 'cursor-not-allowed opacity-50', + (isBusy || task.loopFile) && 'cursor-not-allowed opacity-50', )} + title={task.loopFile + ? t('sessions.scheduledTasks.dialog.loopFile.toggleDisabled') + : undefined} > {task.enabled ? t('sessions.scheduledTasks.dialog.taskToggle.enabled') : t('sessions.scheduledTasks.dialog.taskToggle.paused')} @@ -555,7 +566,10 @@ export function ScheduledTasksDialog() { setEditorTask(task); setEditorOpen(true); }} - disabled={isBusy} + disabled={isBusy || Boolean(task.loopFile)} + title={task.loopFile + ? t('sessions.scheduledTasks.dialog.loopFile.actionsDisabled') + : undefined} aria-label={t('sessions.scheduledTasks.dialog.actions.editAria', { taskName: task.name })} > {t('sessions.scheduledTasks.dialog.actions.edit')} @@ -564,7 +578,10 @@ export function ScheduledTasksDialog() { variant="destructive" size="sm" onClick={() => void handleDeleteTask(task)} - disabled={isBusy} + disabled={isBusy || Boolean(task.loopFile)} + title={task.loopFile + ? t('sessions.scheduledTasks.dialog.loopFile.actionsDisabled') + : undefined} aria-label={t('sessions.scheduledTasks.dialog.actions.deleteAria', { taskName: task.name })} > diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index 57a22de2..0190f560 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -248,6 +248,9 @@ export const dict = { 'sessions.scheduledTasks.dialog.taskToggle.pauseAria': '{taskName} pausieren', 'sessions.scheduledTasks.dialog.taskToggle.enabled': 'Aktiviert', 'sessions.scheduledTasks.dialog.taskToggle.paused': 'Pausiert', + 'sessions.scheduledTasks.dialog.loopFile.note': 'Von Loop-Datei verwaltet {file}', + 'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': 'Aktiviert wird durch die Loop-Datei gesteuert; setze enabled im Markdown-Frontmatter', + 'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': 'Loop-Aufgaben werden in ihrer .agents/loops-Markdown-Datei konfiguriert', 'sessions.scheduledTasks.editor.title.edit': 'Geplante Aufgabe bearbeiten', 'sessions.scheduledTasks.editor.title.new': 'Neue geplante Aufgabe', 'sessions.scheduledTasks.editor.description': 'Konfigurieren Sie eine serverseitige Aufgabe, die eine neue Sitzung erstellt und eine Eingabeaufforderung sendet.', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index c77d6a07..5d0742a0 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -268,6 +268,9 @@ export const dict = { 'sessions.scheduledTasks.dialog.taskToggle.pauseAria': 'Pause {taskName}', 'sessions.scheduledTasks.dialog.taskToggle.enabled': 'Enabled', 'sessions.scheduledTasks.dialog.taskToggle.paused': 'Paused', + 'sessions.scheduledTasks.dialog.loopFile.note': 'Managed by loop file {file}', + 'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': 'Enabled is controlled by the loop file; set enabled in the markdown frontmatter', + 'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': 'Loop tasks are configured in their .agents/loops markdown file', 'sessions.scheduledTasks.editor.title.edit': 'Edit scheduled task', 'sessions.scheduledTasks.editor.title.new': 'New scheduled task', 'sessions.scheduledTasks.editor.description': 'Configure a server-side task that creates a new session and sends a prompt.', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 72985914..9d97a182 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -269,6 +269,9 @@ export const dict: Record = { "sessions.scheduledTasks.dialog.taskToggle.pauseAria": "Pausar {taskName}", "sessions.scheduledTasks.dialog.taskToggle.enabled": "Habilitado", "sessions.scheduledTasks.dialog.taskToggle.paused": "Pausado", + 'sessions.scheduledTasks.dialog.loopFile.note': 'Gestionada por el archivo de bucle {file}', + 'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': 'La activación la controla el archivo de bucle; establece enabled en el frontmatter de Markdown', + 'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': 'Las tareas de bucle se configuran en su archivo Markdown .agents/loops', "sessions.scheduledTasks.editor.title.edit": "Editar tarea programada", "sessions.scheduledTasks.editor.title.new": "Nueva tarea programada", "sessions.scheduledTasks.editor.description": "Configura una tarea del lado del servidor que crea una nueva sesión y envía un prompt.", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 986a4cd4..5d40c830 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -105,6 +105,9 @@ export const dict = { 'sessions.scheduledTasks.dialog.taskToggle.pauseAria': 'Pause {taskName}', 'sessions.scheduledTasks.dialog.taskToggle.enabled': 'Activé', 'sessions.scheduledTasks.dialog.taskToggle.paused': 'En pause', + 'sessions.scheduledTasks.dialog.loopFile.note': 'Gérée par le fichier de boucle {file}', + 'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': "L'activation est contrôlée par le fichier de boucle ; définissez enabled dans le frontmatter Markdown", + 'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': 'Les tâches de boucle sont configurées dans leur fichier Markdown .agents/loops', 'sessions.scheduledTasks.editor.title.edit': 'Modifier une tâche planifiée', 'sessions.scheduledTasks.editor.title.new': 'Nouvelle tâche planifiée', 'sessions.scheduledTasks.editor.description': 'Configurez une tâche côté serveur qui crée une nouvelle session et envoie un prompt.', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 0f4da4e7..352c6727 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -269,6 +269,9 @@ export const dict: Record = { 'sessions.scheduledTasks.dialog.taskToggle.pauseAria': '{taskName}を一時停止', 'sessions.scheduledTasks.dialog.taskToggle.enabled': '有効', 'sessions.scheduledTasks.dialog.taskToggle.paused': '一時停止中', + 'sessions.scheduledTasks.dialog.loopFile.note': 'ループファイル {file} によって管理', + 'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': '有効状態はループファイルが制御します。Markdown フロントマターで enabled を設定してください', + 'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': 'ループタスクは .agents/loops の Markdown ファイルで設定します', 'sessions.scheduledTasks.editor.title.edit': 'スケジュールタスクを編集', 'sessions.scheduledTasks.editor.title.new': '新しいスケジュールタスク', 'sessions.scheduledTasks.editor.description': '新しいセッションを作成しプロンプトを送信するサーバーサイドタスクを設定します。', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 0f09fba2..5ce56da8 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -269,6 +269,9 @@ export const dict: Record = { 'sessions.scheduledTasks.dialog.taskToggle.pauseAria': '{taskName} 일시 중지', 'sessions.scheduledTasks.dialog.taskToggle.enabled': '활성화됨', 'sessions.scheduledTasks.dialog.taskToggle.paused': '일시 중지됨', + 'sessions.scheduledTasks.dialog.loopFile.note': '루프 파일에서 관리됨: {file}', + 'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': '활성화 여부는 루프 파일이 제어합니다. Markdown frontmatter에서 enabled를 설정하세요', + 'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': '루프 작업은 .agents/loops Markdown 파일에서 구성합니다', 'sessions.scheduledTasks.editor.title.edit': '예약 작업 편집', 'sessions.scheduledTasks.editor.title.new': '새 예약 작업', 'sessions.scheduledTasks.editor.description': '새 세션을 만들고 프롬프트를 보내는 서버 작업을 설정합니다.', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 12f727b1..06c5aa7f 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -396,6 +396,9 @@ export const dict: Record = { 'sessions.scheduledTasks.dialog.taskToggle.pauseAria': 'Wstrzymaj {taskName}', 'sessions.scheduledTasks.dialog.taskToggle.enabled': 'Włączone', 'sessions.scheduledTasks.dialog.taskToggle.paused': 'Wstrzymane', + 'sessions.scheduledTasks.dialog.loopFile.note': 'Zarządzane przez plik pętli {file}', + 'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': 'Włączenie jest kontrolowane przez plik pętli; ustaw enabled w frontmatterze Markdown', + 'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': 'Zadania pętli są konfigurowane w pliku Markdown .agents/loops', 'sessions.scheduledTasks.editor.title.edit': 'Edytuj zaplanowane zadanie', 'sessions.scheduledTasks.editor.title.new': 'Nowe zaplanowane zadanie', 'sessions.scheduledTasks.editor.description': 'Skonfiguruj zadanie po stronie serwera, które tworzy nową sesję i wysyła prompt.', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 10dd7709..b33b7976 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -269,6 +269,9 @@ export const dict: Record = { "sessions.scheduledTasks.dialog.taskToggle.pauseAria": "Pausar {taskName}", "sessions.scheduledTasks.dialog.taskToggle.enabled": "Ativado", "sessions.scheduledTasks.dialog.taskToggle.paused": "Pausado", + 'sessions.scheduledTasks.dialog.loopFile.note': 'Gerenciada pelo arquivo de loop {file}', + 'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': 'A ativação é controlada pelo arquivo de loop; defina enabled no frontmatter Markdown', + 'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': 'Tarefas de loop são configuradas no arquivo Markdown .agents/loops', "sessions.scheduledTasks.editor.title.edit": "Editar tarefa agendada", "sessions.scheduledTasks.editor.title.new": "Nova tarefa agendada", "sessions.scheduledTasks.editor.description": "Configure uma tarefa do lado do servidor que cria uma nova sessão e envia um prompt.", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index dd52bcb9..4b389c8d 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -269,6 +269,9 @@ export const dict: Record = { "sessions.scheduledTasks.dialog.taskToggle.pauseAria": "Призупинити {taskName}", "sessions.scheduledTasks.dialog.taskToggle.enabled": "Увімкнено", "sessions.scheduledTasks.dialog.taskToggle.paused": "Призупинено", + 'sessions.scheduledTasks.dialog.loopFile.note': 'Керується файлом циклу {file}', + 'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': 'Активність контролюється файлом циклу; встановіть enabled у frontmatter Markdown', + 'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': 'Завдання циклів налаштовуються у файлі Markdown .agents/loops', "sessions.scheduledTasks.editor.title.edit": "Редагувати заплановане завдання", "sessions.scheduledTasks.editor.title.new": "Нове заплановане завдання", "sessions.scheduledTasks.editor.description": "Налаштувати завдання на стороні сервера, яке створює нову сесію і надсилає запит.", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 35de2c9c..057e6637 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -269,6 +269,9 @@ export const dict: Record = { 'sessions.scheduledTasks.dialog.taskToggle.pauseAria': '暂停 {taskName}', 'sessions.scheduledTasks.dialog.taskToggle.enabled': '已启用', 'sessions.scheduledTasks.dialog.taskToggle.paused': '已暂停', + 'sessions.scheduledTasks.dialog.loopFile.note': '由循环文件 {file} 管理', + 'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': '启用状态由循环文件控制;请在 Markdown frontmatter 中设置 enabled', + 'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': '循环任务在其 .agents/loops Markdown 文件中配置', 'sessions.scheduledTasks.editor.title.edit': '编辑计划任务', 'sessions.scheduledTasks.editor.title.new': '新建计划任务', 'sessions.scheduledTasks.editor.description': '配置一个服务端任务,用于创建新会话并发送提示词。', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 6641606b..7c98d160 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -282,6 +282,9 @@ export const dict: Record = { 'sessions.scheduledTasks.dialog.taskToggle.pauseAria': '暫停 {taskName}', 'sessions.scheduledTasks.dialog.taskToggle.enabled': '已啟用', 'sessions.scheduledTasks.dialog.taskToggle.paused': '已暫停', + 'sessions.scheduledTasks.dialog.loopFile.note': '由迴圈檔案 {file} 管理', + 'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': '啟用狀態由迴圈檔案控制;請在 Markdown frontmatter 中設定 enabled', + 'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': '迴圈任務在其 .agents/loops Markdown 檔案中設定', 'sessions.scheduledTasks.editor.title.edit': '編輯排程任務', 'sessions.scheduledTasks.editor.title.new': '新增排程任務', 'sessions.scheduledTasks.editor.description': '設定一個伺服器端任務,用於建立新會話並傳送提示詞。', diff --git a/packages/ui/src/lib/scheduledTasksApi.ts b/packages/ui/src/lib/scheduledTasksApi.ts index 8b215d64..c7c4aeb1 100644 --- a/packages/ui/src/lib/scheduledTasksApi.ts +++ b/packages/ui/src/lib/scheduledTasksApi.ts @@ -6,6 +6,9 @@ export type ScheduledTask = { id: string; name: string; enabled: boolean; + /** Absolute path of the `.agents/loops/*.md` file driving this task, when + * any. Present only for loop-sourced tasks; unknown to older clients. */ + loopFile?: string; schedule: { kind: 'daily' | 'weekly' | 'once' | 'cron'; times?: string[]; diff --git a/packages/web/server/lib/projects/project-config.js b/packages/web/server/lib/projects/project-config.js index 54166695..ab92383e 100644 --- a/packages/web/server/lib/projects/project-config.js +++ b/packages/web/server/lib/projects/project-config.js @@ -2,7 +2,7 @@ import { DateTime, IANAZone } from 'luxon'; import parser from 'cron-parser'; const PROJECT_CONFIG_VERSION = 1; -const MAX_TASK_NAME_LENGTH = 80; +export const MAX_TASK_NAME_LENGTH = 80; const MAX_TASK_PROMPT_LENGTH = 20_000; const MAX_CRON_LENGTH = 200; const MAX_LAST_ERROR_LENGTH = 2_000; diff --git a/packages/web/server/lib/scheduled-tasks/DOCUMENTATION.md b/packages/web/server/lib/scheduled-tasks/DOCUMENTATION.md index 5bdba849..4de46ab5 100644 --- a/packages/web/server/lib/scheduled-tasks/DOCUMENTATION.md +++ b/packages/web/server/lib/scheduled-tasks/DOCUMENTATION.md @@ -48,7 +48,7 @@ Field mapping (model: `packages/ui/src/lib/scheduledTasksApi.ts`): | Frontmatter | Task field | |---|---| -| `name` | `name` (required) | +| `name` | `name` (required, max 80 characters — longer names are rejected as malformed) | | `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) | @@ -97,7 +97,9 @@ project write lock on every `syncProject` when the project path is known: file remains authoritative: the next reconciliation re-applies the file's definition (including `enabled`). Use `enabled: false` in the file to disable. Deleting a loop-sourced task through the API is rejected with a 400 — - the loop file is the removal surface. + the loop file is the removal surface. The scheduled-tasks UI marks loop tasks + as file-managed and disables their edit/enable/delete actions for the same + reason; `run now` remains available. ## Public exports (runtime.js) diff --git a/packages/web/server/lib/scheduled-tasks/loops.js b/packages/web/server/lib/scheduled-tasks/loops.js index 1fd71be7..7d15e671 100644 --- a/packages/web/server/lib/scheduled-tasks/loops.js +++ b/packages/web/server/lib/scheduled-tasks/loops.js @@ -41,6 +41,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; import { parseMdFile, getAncestors, findWorktreeRoot } from '../opencode/shared.js'; +import { MAX_TASK_NAME_LENGTH } from '../projects/project-config.js'; const LOOP_DIR_NAME = 'loops'; const USER_LOOP_ROOT = () => path.join(os.homedir(), '.agents', LOOP_DIR_NAME); @@ -94,6 +95,12 @@ export const parseLoopDefinition = (filePath) => { console.warn(`[loops] skipped ${filePath}: frontmatter "name" is required`); return null; } + if (name.length > MAX_TASK_NAME_LENGTH) { + // Reject instead of clamping: task names are clamped to this length at + // storage time, so identity keys must match the stored value exactly. + console.warn(`[loops] skipped ${filePath}: frontmatter "name" exceeds ${MAX_TASK_NAME_LENGTH} characters`); + return null; + } const cron = asNonEmptyString(frontmatter.schedule); if (!cron) { diff --git a/packages/web/server/lib/scheduled-tasks/loops.test.js b/packages/web/server/lib/scheduled-tasks/loops.test.js index 7281adda..e05ef980 100644 --- a/packages/web/server/lib/scheduled-tasks/loops.test.js +++ b/packages/web/server/lib/scheduled-tasks/loops.test.js @@ -181,6 +181,33 @@ model: openai/gpt-5 await cleanup(); } }); + + it('rejects names longer than the storage limit', async () => { + const { projectPath, cleanup } = await createProject(); + try { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + try { + const filePath = path.join(projectPath, 'long-name.md'); + await writeFile(filePath, `--- +name: ${'x'.repeat(81)} +schedule: "0 9 * * *" +model: openai/gpt-5 +--- +Run. +`, 'utf8'); + + // Task names are clamped to 80 chars at storage time; a raw name that + // exceeds it could never match the stored task, so the file is treated + // as malformed rather than creating an unreachable definition. + expect(parseLoopDefinition(filePath)).toBeNull(); + expect(warn).toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + } finally { + await cleanup(); + } + }); }); describe('discoverLoops', () => { From 9b6b90504ceb3e9a5d9f3bd596be78c3551f37fc Mon Sep 17 00:00:00 2001 From: makeittech Date: Thu, 6 Aug 2026 09:49:31 +0300 Subject: [PATCH 4/5] fix(tasks): cover syncProject wiring and allow deleting orphans after file removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up: - runtime.test.js: add syncProject wiring tests with a real temp-dir project and real project-config runtime — asserts reconcileLoopTasks is driven with the discovered loops when the project path is known (task created, nextRunAt computed) and that plain listing is used when the path cannot be resolved (reconcile not called). - service.js: DELETE on a loop-owned task is rejected with a 400 only while its loop file still exists on disk; once the file is gone the orphan task can be deleted directly instead of waiting for the next reconcile. Tests use real temp files for both branches. - DOCUMENTATION.md: delete semantics updated accordingly. - PR description refreshed for the final HEAD (test counts, reconciliation contract, evidence wording). --- .../lib/scheduled-tasks/DOCUMENTATION.md | 9 +- .../lib/scheduled-tasks/runtime.test.js | 101 +++++++++++++++++- .../web/server/lib/scheduled-tasks/service.js | 9 +- .../lib/scheduled-tasks/service.test.js | 58 +++++++--- 4 files changed, 156 insertions(+), 21 deletions(-) diff --git a/packages/web/server/lib/scheduled-tasks/DOCUMENTATION.md b/packages/web/server/lib/scheduled-tasks/DOCUMENTATION.md index 4de46ab5..4be03824 100644 --- a/packages/web/server/lib/scheduled-tasks/DOCUMENTATION.md +++ b/packages/web/server/lib/scheduled-tasks/DOCUMENTATION.md @@ -96,10 +96,11 @@ project write lock on every `syncProject` when the project path is known: - **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. Deleting a loop-sourced task through the API is rejected with a 400 — - the loop file is the removal surface. The scheduled-tasks UI marks loop tasks - as file-managed and disables their edit/enable/delete actions for the same - reason; `run now` remains available. + disable. Deleting a loop-sourced task through the API is rejected with a 400 + while its loop file still exists on disk — the loop file is the removal + surface; once the file is gone, deleting the orphan task is allowed. The + scheduled-tasks UI marks loop tasks as file-managed and disables their + edit/enable/delete actions for the same reason; `run now` remains available. ## Public exports (runtime.js) diff --git a/packages/web/server/lib/scheduled-tasks/runtime.test.js b/packages/web/server/lib/scheduled-tasks/runtime.test.js index 7dafaca9..3a59b19f 100644 --- a/packages/web/server/lib/scheduled-tasks/runtime.test.js +++ b/packages/web/server/lib/scheduled-tasks/runtime.test.js @@ -1,5 +1,15 @@ -import { describe, expect, it } from 'vitest'; -import { computeNextRunAt, expandCommandGoalObjective, formatScheduledSessionTitle, parseScheduledCommandPrompt } from './runtime.js'; +import { describe, expect, it, vi } from 'vitest'; +import os from 'os'; +import path from 'path'; +import { mkdtemp, rm, mkdir, writeFile } from 'fs/promises'; +import { + computeNextRunAt, + expandCommandGoalObjective, + formatScheduledSessionTitle, + parseScheduledCommandPrompt, + createScheduledTasksRuntime, +} from './runtime.js'; +import { createProjectConfigRuntime } from '../projects/project-config.js'; describe('scheduled-tasks runtime helpers', () => { it('computes next daily run in timezone', () => { @@ -109,3 +119,90 @@ describe('scheduled-tasks runtime helpers', () => { .toBe('Review the requested scope.\n\nauth module'); }); }); + +describe('scheduled-tasks runtime syncProject wiring', () => { + const createTempProject = async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'oc-runtime-loop-')); + const repoPath = path.join(tempRoot, 'repo'); + await mkdir(path.join(repoPath, '.agents', 'loops'), { recursive: true }); + return { + tempRoot, + repoPath, + cleanup: async () => { + await rm(tempRoot, { recursive: true, force: true }); + }, + }; + }; + + const createProjectConfig = async (tempRoot) => createProjectConfigRuntime({ + fsPromises: await import('fs/promises'), + path, + projectsDirPath: path.join(tempRoot, 'config'), + createTaskID: () => 'task-fixed-id', + }); + + const createRuntimeDeps = (overrides = {}) => ({ + buildOpenCodeUrl: () => 'http://localhost', + getOpenCodeAuthHeaders: () => ({}), + waitForOpenCodeReady: async () => {}, + ...overrides, + }); + + it('reconciles discovered loops when the project path is known', async () => { + const { tempRoot, repoPath, cleanup } = await createTempProject(); + try { + await writeFile(path.join(repoPath, '.agents', 'loops', 'daily.md'), `--- +name: daily +schedule: "0 9 * * *" +enabled: true +model: openai/gpt-5 +--- +Run daily. +`, 'utf8'); + + const projectConfigRuntime = await createProjectConfig(tempRoot); + const runtime = createScheduledTasksRuntime({ + ...createRuntimeDeps(), + projectConfigRuntime, + listProjects: async () => [{ id: 'proj', path: repoPath }], + }); + + await runtime.syncProject('proj'); + + const tasks = await projectConfigRuntime.listScheduledTasks('proj'); + expect(tasks).toHaveLength(1); + expect(tasks[0].id).toBe('loop:project:daily'); + expect(tasks[0].loopFile).toBe(path.join(repoPath, '.agents', 'loops', 'daily.md')); + // syncTaskSchedule computed and persisted the next run for the enabled task. + expect(tasks[0].state.nextRunAt).toBeGreaterThan(0); + } finally { + await cleanup(); + } + }); + + it('falls back to plain listing when the project path cannot be resolved', async () => { + const { tempRoot, cleanup } = await createTempProject(); + try { + const projectConfigRuntime = await createProjectConfig(tempRoot); + const reconcileSpy = vi.spyOn(projectConfigRuntime, 'reconcileLoopTasks'); + const listSpy = vi.spyOn(projectConfigRuntime, 'listScheduledTasks'); + + const runtime = createScheduledTasksRuntime({ + ...createRuntimeDeps(), + projectConfigRuntime, + // Project not registered -> ensureProjectPath cannot resolve a path. + listProjects: async () => [], + }); + + await runtime.syncProject('proj'); + + expect(reconcileSpy).not.toHaveBeenCalled(); + expect(listSpy).toHaveBeenCalledWith('proj'); + expect(await projectConfigRuntime.listScheduledTasks('proj')).toEqual([]); + reconcileSpy.mockRestore(); + listSpy.mockRestore(); + } finally { + await cleanup(); + } + }); +}); diff --git a/packages/web/server/lib/scheduled-tasks/service.js b/packages/web/server/lib/scheduled-tasks/service.js index 33d69f7d..4d94251a 100644 --- a/packages/web/server/lib/scheduled-tasks/service.js +++ b/packages/web/server/lib/scheduled-tasks/service.js @@ -1,3 +1,4 @@ +import fs from 'node:fs'; import path from 'node:path'; import { OpenChamberControlError } from '../openchamber-control/error.js'; @@ -80,10 +81,12 @@ export const createScheduledTaskService = (dependencies) => { if (!normalizedTaskID) throw new OpenChamberControlError('taskId is required', 400); const current = await projectConfigRuntime.listScheduledTasks(projectID); const existing = current.find((task) => task.id === normalizedTaskID) || null; - if (existing?.loopFile) { + if (existing?.loopFile && fs.existsSync(existing.loopFile)) { // Loop tasks are owned by their `.agents/loops` markdown file: deleting - // the JSON row would be silently undone by the next reconcile. The file - // itself is the removal surface. + // the JSON row would be silently undone by the next reconcile while the + // file exists. The file itself is the removal surface. Once the file is + // gone (the task is an orphan that the next sync would remove anyway), + // deleting the row is safe and allowed. throw new OpenChamberControlError( 'Loop task is managed by its .agents/loops markdown file; delete the file to remove the task', 400, diff --git a/packages/web/server/lib/scheduled-tasks/service.test.js b/packages/web/server/lib/scheduled-tasks/service.test.js index 07d6bf70..a90c1b64 100644 --- a/packages/web/server/lib/scheduled-tasks/service.test.js +++ b/packages/web/server/lib/scheduled-tasks/service.test.js @@ -1,4 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; +import os from 'os'; +import path from 'path'; +import { mkdtemp, rm, writeFile } from 'fs/promises'; import { createScheduledTaskService } from './service.js'; const createService = (overrides = {}) => { @@ -32,19 +35,50 @@ const loopTask = { }; describe('scheduled-task service remove', () => { - it('rejects deleting a loop-sourced task without touching storage', async () => { - const { service, projectConfigRuntime, scheduledTasksRuntime } = createService({ - projectConfigRuntime: { - listScheduledTasks: vi.fn(async () => [loopTask]), - }, - }); + it('rejects deleting a loop-sourced task while its loop file still exists', async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'oc-loop-delete-')); + try { + const loopFilePath = path.join(tempRoot, 'daily.md'); + await writeFile(loopFilePath, '---\nname: daily-digest\n---\nRun.\n', 'utf8'); - await expect(service.remove('project-test', loopTask.id)).rejects.toMatchObject({ - statusCode: 400, - message: expect.stringContaining('delete the file to remove the task'), - }); - expect(projectConfigRuntime.deleteScheduledTask).not.toHaveBeenCalled(); - expect(scheduledTasksRuntime.syncProject).not.toHaveBeenCalled(); + const { service, projectConfigRuntime, scheduledTasksRuntime } = createService({ + projectConfigRuntime: { + listScheduledTasks: vi.fn(async () => [{ ...loopTask, loopFile: loopFilePath }]), + }, + }); + + await expect(service.remove('project-test', loopTask.id)).rejects.toMatchObject({ + statusCode: 400, + message: expect.stringContaining('delete the file to remove the task'), + }); + expect(projectConfigRuntime.deleteScheduledTask).not.toHaveBeenCalled(); + expect(scheduledTasksRuntime.syncProject).not.toHaveBeenCalled(); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } + }); + + it('allows deleting a loop-sourced task once its loop file is gone', async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'oc-loop-delete-')); + try { + // The loop file was removed from disk; the orphan task is allowed to be + // deleted directly instead of waiting for the next reconcile. + const loopFilePath = path.join(tempRoot, 'gone.md'); + + const { service, projectConfigRuntime, scheduledTasksRuntime } = createService({ + projectConfigRuntime: { + listScheduledTasks: vi.fn(async () => [{ ...loopTask, loopFile: loopFilePath }]), + }, + }); + + const tasks = await service.remove('project-test', loopTask.id); + + expect(projectConfigRuntime.deleteScheduledTask).toHaveBeenCalledWith('project-test', loopTask.id); + expect(scheduledTasksRuntime.syncProject).toHaveBeenCalled(); + expect(Array.isArray(tasks)).toBe(true); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } }); it('deletes JSON-configured tasks normally', async () => { From 0a4fd7c5fb93a2e082555956e0f535d252abfd47 Mon Sep 17 00:00:00 2001 From: makeittech Date: Thu, 6 Aug 2026 09:56:11 +0300 Subject: [PATCH 5/5] docs(tasks): add loops quick-start to the scheduled-tasks page User-facing onboarding for markdown loop tasks: where .agents/loops files live (project + user scope), a copy-paste sample file, the frontmatter field table, and the behavior contract (file authoritative, off by default, rename/malformed semantics, run-now still available). Also lists the cron schedule type in the UI task creation steps, which the page previously omitted. --- .../docs/content/docs/scheduled-tasks.mdx | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/packages/docs/content/docs/scheduled-tasks.mdx b/packages/docs/content/docs/scheduled-tasks.mdx index e7753190..4f5a6554 100644 --- a/packages/docs/content/docs/scheduled-tasks.mdx +++ b/packages/docs/content/docs/scheduled-tasks.mdx @@ -15,6 +15,7 @@ A scheduled task runs a prompt for you on a schedule — for example, a daily "s - **daily** — at one or more times each day - **weekly** — on chosen weekdays and times - **once** — a single date and time + - **cron** — an arbitrary cron expression 4. Set what it does: the prompt to send, and the provider, model, and agent to use. The prompt can be a slash command, like `/review`. 5. Save, and make sure the task is enabled. @@ -22,6 +23,48 @@ You can run any task immediately with **run now** to check it does what you expe Check **Run as goal** to make the run pursue its prompt to completion instead of stopping after one reply — see [Session Goals](/session-goals/). +## Loops: scheduled tasks as markdown files + +A **loop** is a scheduled task defined as a portable markdown file you can commit to your repo. Drop a file into `.agents/loops/` and the task appears on the next sync — no dialog needed: + +```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 and post the digest. +``` + +### Where files live + +- **Project scope** — `.agents/loops/*.md` in the project directory or any ancestor directory up to the git worktree root. +- **User scope** — `~/.agents/loops/*.md` applies to every project you open. + +If a project loop and a user loop share a name, the project loop wins. + +### Fields + +| Field | Meaning | +|---|---| +| `name` | Task name (required, max 80 characters). | +| `schedule` | Cron expression (required) — loop files are cron-only. | +| `enabled` | Set `true` to run. Loops are **off by default**, so committing a file never starts running a task on its own. | +| `model` | `provider/model` (required), e.g. `anthropic/claude-sonnet-4-5`. | +| `agent` | Agent to use (optional). | +| `timezone` | IANA timezone (optional, defaults to the server zone). | +| body | The execution prompt (required). Can be a slash command, like `/review src/`. | + +### How loops behave + +- The **file is authoritative** while it exists: edits made in the UI are reverted on the next sync. The scheduled-tasks dialog marks loop tasks and disables their edit/enable/delete actions — **run now** still works. To stop a loop, delete the file (or set `enabled: false`). +- Runtime state (last run, next run, status) lives in the project config and is never written back into the markdown file. +- Renaming the `name` field renames the task in place. If a loop file temporarily fails to parse (mid-edit, merge conflict), its task is kept with the last good definition until the file is fixed. +- `daily`/`weekly`/`once` schedules and goal settings remain UI-only; loop files are always cron. + ## What success looks like After a run, the task shows when it last ran, whether it succeeded, and a link to the session it created. If a run fails, the error is shown there too.