Merge pull request #2698 from makeittech/feat/gh-2583-markdown-loops

feat(tasks): support markdown scheduled-task loops in .agents/loops
This commit is contained in:
Serhii Dziupin
2026-08-06 09:57:49 +03:00
committed by GitHub
23 changed files with 1441 additions and 10 deletions
@@ -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:<scope>:<name>` 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,
};
};
@@ -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();
}
});
});
@@ -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:<scope>:<name>` id so runtime state survives restarts. At most one task
is driven per loop file; orphan duplicates of the same file are unscheduled.
- **Scope precedence.** Project-scope loops shadow user-scope loops with the
same name; among project files the nearest ancestor wins.
- **Malformed files** (missing `name`/`schedule`/`model`/body, invalid cron,
unreadable) are reported to the scheduler 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)`
@@ -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;
};
@@ -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();
}
});
});
@@ -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) {
@@ -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();
}
});
});
@@ -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);
@@ -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);
});
});