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.
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 0814ed97..4bda736a 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 895faaae..2ba921aa 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 21bcd484..8b2a1342 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 a94c8ff7..f9465c52 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 a135d9e5..d2c1fed8 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 84c41bcc..302fc278 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 3e1cb4e4..a8dacf18 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 333666ce..1387d286 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 2680f0c3..3856769b 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 5a36af20..bb328a56 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 846aa35c..584f0590 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 ed8d98b6..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;
@@ -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,141 @@ export const createProjectConfigRuntime = (deps) => {
});
};
+ /**
+ * Reconcile discovered `.agents/loops` definitions with the persisted JSON
+ * task list.
+ *
+ * Rules (documented in scheduled-tasks/DOCUMENTATION.md):
+ * - 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 them as `definition: null` entries, 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();
+ const loopsByPath = new Map();
+ for (const loop of loops) {
+ if (!loop || typeof loop.filePath !== 'string' || !loop.filePath) {
+ continue;
+ }
+ activeLoopFilePaths.add(loop.filePath);
+ 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)) {
+ // The driving loop file was removed (or renamed) — unschedule.
+ continue;
+ }
+
+ // 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,
+ // 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,
+ existingTask: task,
+ allowCreate: false,
+ refreshUpdatedAt: false,
+ },
+ );
+ 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);
+ }
+ 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);
+ }
+
+ 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..408866c8 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,302 @@ 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();
+ }
+ });
+
+ 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 2fc61770..4be03824 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,90 @@ 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, 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) |
+| `agent` | `execution.agent` (optional) |
+| `timezone` | `schedule.timezone` (optional, IANA; defaults to the server zone) |
+| body | `execution.prompt` (required) |
+
+`thinking_level` and `goalEnabled`/`goalTokenBudget` are not part of the portable
+format (UI/JSON-only today); `daily`/`weekly`/`once` schedules remain UI/JSON-only.
+Runtime state (`lastRunAt`, `nextRunAt`, `lastStatus`, `lastError`, `lastSessionId`,
+`lastDurationMs`) is never written to the markdown file — it continues to live in
+the project config state store.
+
+## Loop reconciliation rules
+
+`projectConfigRuntime.reconcileLoopTasks(projectID, loops)` runs inside the
+project write lock on every `syncProject` when the project path is known:
+
+- **Identity.** For loop-owned tasks (carrying the `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. At most one task
+ is driven per loop file; orphan duplicates of the same file are unscheduled.
+- **Scope precedence.** Project-scope loops shadow user-scope loops with the
+ same name; among project files the nearest ancestor wins.
+- **Malformed files** (missing `name`/`schedule`/`model`/body, invalid cron,
+ unreadable) are reported to the scheduler 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. 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)
- `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..7d15e671
--- /dev/null
+++ b/packages/web/server/lib/scheduled-tasks/loops.js
@@ -0,0 +1,209 @@
+/**
+ * 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 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)
+ * 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';
+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);
+
+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;
+ }
+ 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) {
+ 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 : false,
+ 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.
+ *
+ * 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);
+ if (existing && (existing.scope === 'project' || scope === 'user')) {
+ continue;
+ }
+ byName.set(definition.name, { scope, filePath, definition });
+ }
+ 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
new file mode 100644
index 00000000..e05ef980
--- /dev/null
+++ b/packages/web/server/lib/scheduled-tasks/loops.test.js
@@ -0,0 +1,389 @@
+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(false);
+ } finally {
+ await cleanup();
+ }
+ });
+
+ it('defaults enabled to false 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
+---
+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();
+ } finally {
+ await cleanup();
+ }
+ });
+
+ 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 {
+ 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();
+ }
+ });
+
+ 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', () => {
+ 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('reports malformed files as unparsed entries 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);
+
+ // 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();
+ }
+ } 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) {
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 7d44f4e8..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';
@@ -78,6 +79,19 @@ 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 && 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 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,
+ );
+ }
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..a90c1b64
--- /dev/null
+++ b/packages/web/server/lib/scheduled-tasks/service.test.js
@@ -0,0 +1,99 @@
+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 = {}) => {
+ 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 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');
+
+ 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 () => {
+ 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);
+ });
+});