feat(tasks): support markdown scheduled-task loops in .agents/loops

Adds markdown-based scheduled-task definitions ("loops") discovered from
.agents/loops/*.md (project scope, ancestor directories up to the
worktree root) and ~/.agents/loops/*.md (user scope), mirroring the
skills discovery pattern.

File format: YAML frontmatter (name, schedule cron, enabled, model as
provider/model, optional agent/timezone) plus the markdown body as the
execution prompt. Discovery and parsing live in
scheduled-tasks/loops.js; project-config gains reconcileLoopTasks which
runs inside the project write lock on every syncProject:
- identity by task name; a loop takes over a matching task, preserving
  its id and runtime state (markdown wins on conflict with JSON)
- tasks whose loopFile is gone are unscheduled; JSON tasks are never
  removed
- new loops are created under deterministic loop:<scope>:<name> ids
- project scope shadows user scope on name collisions
- malformed files are skipped with a warning and never block valid ones

Runtime state stays in the project config/state store; it is never
written to the markdown files. Module documentation updated with the
file format and reconciliation rules.

Fixes #2583
This commit is contained in:
Serhii Dziupin
2026-08-05 14:11:28 +03:00
parent 34c221b07f
commit 2fcfe511a5
6 changed files with 861 additions and 3 deletions
@@ -313,6 +313,11 @@ const normalizeTaskForStorage = (value, options) => {
const schedule = normalizeSchedule(value.schedule, existingTask?.schedule);
const execution = normalizeExecution(value.execution);
// Loop provenance: absolute path of the `.agents/loops/*.md` file driving
// this task, when any. Preserved on every write so the scheduler can detect
// removed loop files across restarts. Unknown to the UI model.
const loopFile = asNonEmptyString(value.loopFile) ?? asNonEmptyString(existingTask?.loopFile);
const nowMs = Math.max(0, Math.round(now));
const baseState = normalizeState(value.state, existingTask?.state);
const state = {
@@ -328,6 +333,7 @@ const normalizeTaskForStorage = (value, options) => {
schedule,
execution,
state,
...(loopFile ? { loopFile } : {}),
};
};
@@ -559,11 +565,106 @@ export const createProjectConfigRuntime = (deps) => {
});
};
/**
* Reconcile discovered `.agents/loops` definitions with the persisted JSON
* task list.
*
* Rules (documented in scheduled-tasks/DOCUMENTATION.md):
* - Identity is the task NAME. A loop whose name matches an existing task
* takes that task over: its schedule/execution/enabled are overwritten
* from the file while its id and runtime state are preserved (markdown
* wins on conflict).
* - A task whose loopFile no longer matches any discovered loop file is
* unscheduled (removed). JSON-configured tasks (no loopFile) are never
* removed.
* - Loops with no matching task are created under a deterministic
* `loop:<scope>:<name>` id, so runtime state survives restarts.
* - Malformed definitions are skipped with a warning and never block valid
* loops; the scheduler passes only parsed definitions, and normalization
* failures here are isolated per loop.
*/
const reconcileLoopTasks = async (projectID, loops) => {
return withProjectWriteLock(projectID, async () => {
const now = Date.now();
const current = await readProjectConfigFromDisk(projectID);
const tasks = current.scheduledTasks;
const activeLoopFilePaths = new Set();
const pendingLoops = new Map();
for (const loop of loops) {
if (!loop || !loop.definition || typeof loop.definition !== 'object') {
continue;
}
activeLoopFilePaths.add(loop.filePath);
pendingLoops.set(loop.definition.name, loop);
}
const nextTasks = [];
for (const task of tasks) {
if (task.loopFile && !activeLoopFilePaths.has(task.loopFile)) {
// The driving loop file was removed (or renamed) — unschedule.
continue;
}
const loop = pendingLoops.get(task.name);
if (loop) {
try {
const adopted = normalizeTaskForStorage(
{ ...task, ...loop.definition, loopFile: loop.filePath },
{
now,
createId: taskIDFactory,
existingTask: task,
allowCreate: false,
refreshUpdatedAt: false,
},
);
nextTasks.push(adopted);
pendingLoops.delete(loop.definition.name);
} catch (error) {
console.warn(`[scheduled-tasks] skipped loop ${loop.filePath} for task "${task.name}":`, error?.message ?? error);
nextTasks.push(task);
}
continue;
}
nextTasks.push(task);
}
for (const loop of pendingLoops.values()) {
try {
const id = `loop:${loop.scope}:${loop.definition.name}`;
const created = normalizeTaskForStorage(
{ id, ...loop.definition, loopFile: loop.filePath },
{
now,
createId: taskIDFactory,
existingTask: null,
allowCreate: true,
refreshUpdatedAt: false,
},
);
nextTasks.push(created);
} catch (error) {
console.warn(`[scheduled-tasks] skipped loop ${loop.filePath}:`, error?.message ?? error);
}
}
await writeProjectConfigToDisk(projectID, {
version: PROJECT_CONFIG_VERSION,
scheduledTasks: nextTasks,
});
return nextTasks;
});
};
return {
listScheduledTasks,
upsertScheduledTask,
deleteScheduledTask,
updateScheduledTaskState,
reconcileLoopTasks,
resolveProjectConfigPath,
};
};
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import os from 'os';
import path from 'path';
import { mkdtemp, rm, readFile, writeFile } from 'fs/promises';
@@ -173,3 +173,160 @@ describe('project-config runtime', () => {
}
});
});
describe('project-config loop reconciliation', () => {
const loop = (name, overrides = {}) => ({
scope: 'project',
filePath: `/repo/.agents/loops/${name}.md`,
definition: {
name,
enabled: true,
schedule: { kind: 'cron', cron: '0 9 * * *', timezone: 'UTC' },
execution: {
prompt: `Loop prompt for ${name}`,
providerID: 'openai',
modelID: 'gpt-4.1',
},
...overrides,
},
});
it('creates tasks for discovered loops with deterministic ids', async () => {
const { runtime, cleanup } = await createRuntime();
try {
const tasks = await runtime.reconcileLoopTasks('project-test', [
loop('daily-digest'),
loop('weekly-report'),
]);
expect(tasks).toHaveLength(2);
const digest = tasks.find((task) => task.name === 'daily-digest');
expect(digest.id).toBe('loop:project:daily-digest');
expect(digest.schedule.cron).toBe('0 9 * * *');
expect(digest.execution.providerID).toBe('openai');
expect(digest.loopFile).toBe('/repo/.agents/loops/daily-digest.md');
const reloaded = await runtime.listScheduledTasks('project-test');
expect(reloaded).toHaveLength(2);
expect(reloaded[0].state.createdAt).toBeGreaterThan(0);
} finally {
await cleanup();
}
});
it('adopts an existing task by name, preserving id and state, and persists state across reconciles', async () => {
const { runtime, cleanup } = await createRuntime();
try {
const created = await runtime.upsertScheduledTask('project-test', {
name: 'daily-digest',
enabled: true,
schedule: { kind: 'daily', time: '09:30', timezone: 'UTC' },
execution: { prompt: 'JSON prompt', providerID: 'openai', modelID: 'gpt-4.1' },
});
const first = await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]);
const adopted = first.find((task) => task.id === created.task.id);
expect(adopted).toBeDefined();
expect(adopted.id).toBe(created.task.id);
expect(adopted.name).toBe('daily-digest');
expect(adopted.schedule.kind).toBe('cron');
expect(adopted.schedule.cron).toBe('0 9 * * *');
expect(adopted.execution.prompt).toBe('Loop prompt for daily-digest');
expect(adopted.loopFile).toBe('/repo/.agents/loops/daily-digest.md');
const state = adopted.state;
await runtime.updateScheduledTaskState('project-test', adopted.id, {
nextRunAt: 123456,
lastRunAt: 111,
lastStatus: 'success',
});
const second = await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]);
const again = second.find((task) => task.id === created.task.id);
expect(again.id).toBe(created.task.id);
expect(again.state.nextRunAt).toBe(123456);
expect(again.state.lastRunAt).toBe(111);
expect(again.state.lastStatus).toBe('success');
expect(again.loopFile).toBe('/repo/.agents/loops/daily-digest.md');
} finally {
await cleanup();
}
});
it('unschedules a loop-sourced task when its file is removed', async () => {
const { runtime, cleanup } = await createRuntime();
try {
await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]);
const tasks = await runtime.reconcileLoopTasks('project-test', []);
expect(tasks).toHaveLength(0);
expect(await runtime.listScheduledTasks('project-test')).toHaveLength(0);
} finally {
await cleanup();
}
});
it('leaves JSON-configured tasks untouched when no loop matches', async () => {
const { runtime, cleanup } = await createRuntime();
try {
const created = await runtime.upsertScheduledTask('project-test', {
name: 'json-only',
enabled: true,
schedule: { kind: 'daily', time: '08:00', timezone: 'UTC' },
execution: { prompt: 'JSON prompt', providerID: 'openai', modelID: 'gpt-4.1' },
});
const tasks = await runtime.reconcileLoopTasks('project-test', [loop('loop-only')]);
expect(tasks).toHaveLength(2);
expect(tasks.find((task) => task.id === created.task.id)).toBeDefined();
expect(tasks.find((task) => task.name === 'loop-only')).toBeDefined();
} finally {
await cleanup();
}
});
it('does not remove a JSON task that merely shares a loop name after the loop is gone... keeps it when never adopted', async () => {
// A JSON task that was never driven by a loop file (no loopFile marker)
// must survive reconciles even when a loop with the same name existed
// only in a previous reconcile round — but once a loop adopted it, the
// file is authoritative and removing the file unschedules the task.
const { runtime, cleanup } = await createRuntime();
try {
const created = await runtime.upsertScheduledTask('project-test', {
name: 'daily-digest',
enabled: true,
schedule: { kind: 'daily', time: '09:30', timezone: 'UTC' },
execution: { prompt: 'JSON prompt', providerID: 'openai', modelID: 'gpt-4.1' },
});
// First reconcile adopts the task (loopFile marker set).
await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]);
// Loop file removed -> task unscheduled.
const afterRemoval = await runtime.reconcileLoopTasks('project-test', []);
expect(afterRemoval.find((task) => task.id === created.task.id)).toBeUndefined();
} finally {
await cleanup();
}
});
it('skips invalid loop definitions without blocking valid ones', async () => {
const { runtime, cleanup } = await createRuntime();
try {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
try {
const tasks = await runtime.reconcileLoopTasks('project-test', [
loop('bad-loop', { schedule: { kind: 'cron', cron: 'not a cron', timezone: 'UTC' } }),
loop('good-loop'),
]);
expect(tasks.map((task) => task.name)).toEqual(['good-loop']);
expect(warn).toHaveBeenCalled();
} finally {
warn.mockRestore();
}
} finally {
await cleanup();
}
});
});
@@ -5,7 +5,8 @@ Server-owned scheduled task runtime and routes for OpenChamber-only automation.
## Scope
- Per-project scheduled task persistence is owned by `packages/web/server/lib/projects/project-config.js`.
- Runtime orchestration and execution is owned by this module.
- Markdown loop discovery/parsing is owned by `packages/web/server/lib/scheduled-tasks/loops.js`.
- Runtime orchestration and execution is owned by `packages/web/server/lib/scheduled-tasks/runtime.js`.
- This module is OpenChamber feature logic; it is intentionally separate from OpenCode proxy/runtime internals.
## Files
@@ -17,11 +18,74 @@ Server-owned scheduled task runtime and routes for OpenChamber-only automation.
- Session create + prompt_async execution
- Emits OpenChamber task-run events
- `packages/web/server/lib/scheduled-tasks/loops.js`
- Discovery of `.agents/loops/*.md` (project scope, ancestors up to the worktree root) and `~/.agents/loops/*.md` (user scope)
- Frontmatter parsing into scheduled-task definitions
- `syncProject` reconciles discovered loops with the persisted task list on every project sync (startup, task save/delete)
- `packages/web/server/lib/scheduled-tasks/routes.js`
- Scheduled task CRUD endpoints
- Manual run endpoint
- OpenChamber events SSE stream endpoint
## Loop file format
Portable, git-commit-able scheduled-task definitions:
```markdown
---
name: daily-digest
schedule: "0 9 * * *"
enabled: true
model: anthropic/claude-sonnet-4-5
agent: plan
timezone: Europe/Kyiv
---
Summarize repository changes since yesterday.
```
Field mapping (model: `packages/ui/src/lib/scheduledTasksApi.ts`):
| Frontmatter | Task field |
|---|---|
| `name` | `name` (required) |
| `schedule` | `schedule.kind: "cron"` + `schedule.cron` (required, cron-only in the portable format) |
| `enabled` | `enabled` (default `true`) |
| `model` | split on the first `/` into `execution.providerID` / `execution.modelID` (required) |
| `agent` | `execution.agent` (optional) |
| `timezone` | `schedule.timezone` (optional, IANA; defaults to the server zone) |
| body | `execution.prompt` (required) |
`thinking_level` and `goalEnabled`/`goalTokenBudget` are not part of the portable
format (UI/JSON-only today); `daily`/`weekly`/`once` schedules remain UI/JSON-only.
Runtime state (`lastRunAt`, `nextRunAt`, `lastStatus`, `lastError`, `lastSessionId`,
`lastDurationMs`) is never written to the markdown file — it continues to live in
the project config state store.
## Loop reconciliation rules
`projectConfigRuntime.reconcileLoopTasks(projectID, loops)` runs inside the
project write lock on every `syncProject` when the project path is known:
- **Identity is the task name.** A loop whose name matches an existing task
takes that task over: its schedule/execution/enabled are overwritten from the
file while the task's `id` and runtime `state` are preserved (markdown wins
on conflict with JSON-configured tasks).
- **Deletion.** A task carrying the `loopFile` marker whose loop file is no
longer discovered (removed or renamed) is unscheduled (removed from the
config). The marker is persisted in the config file, so removal is detected
across restarts. JSON-configured tasks without the marker are never removed.
- **Creation.** Loops without a matching task are created under a deterministic
`loop:<scope>:<name>` id so runtime state survives restarts.
- **Scope precedence.** Project-scope loops shadow user-scope loops with the
same name; among project files the nearest ancestor wins.
- **Malformed files** (missing `name`/`schedule`/`model`/body, invalid cron,
unreadable) are skipped with a warning and never block valid loops in the
same or other scopes.
- **UI edits** to a loop-sourced task are preserved in the config but the loop
file remains authoritative: the next reconciliation re-applies the file's
definition (including `enabled`). Use `enabled: false` in the file to disable.
## Public exports (runtime.js)
- `createScheduledTasksRuntime(dependencies)`
@@ -0,0 +1,189 @@
/**
* Markdown loops portable scheduled-task definitions.
*
* Loops are git-commit-able markdown files with YAML frontmatter, discovered
* from `.agents/loops/*.md` (project scope, including ancestor directories up
* to the worktree root) and `~/.agents/loops/*.md` (user scope), mirroring the
* skills discovery pattern (`packages/web/server/lib/opencode/skills.js`).
*
* File format:
*
* ---
* name: daily-digest
* schedule: "0 9 * * *"
* enabled: true
* model: anthropic/claude-sonnet-4-5
* agent: plan
* timezone: Europe/Kyiv
* ---
* Summarize repository changes since yesterday and post the digest.
*
* Field mapping (see packages/ui/src/lib/scheduledTasksApi.ts):
* name -> task.name
* schedule -> task.schedule.kind "cron" + task.schedule.cron
* enabled -> task.enabled (default true)
* model -> split into task.execution.providerID / task.execution.modelID
* agent -> task.execution.agent (optional)
* timezone -> task.schedule.timezone (optional, defaults to the server zone)
* body -> task.execution.prompt
*
* `thinking_level` and `goalEnabled`/`goalTokenBudget` are not part of the
* portable format (they are UI-only today); editing them in the file has no
* effect and they remain JSON/UI-only.
*
* Runtime state (lastRunAt, nextRunAt, lastStatus, ...) is never written to
* the markdown file; it continues to live in the project config/state store.
*/
import fs from 'fs';
import os from 'os';
import path from 'path';
import { parseMdFile, getAncestors, findWorktreeRoot } from '../opencode/shared.js';
const LOOP_DIR_NAME = 'loops';
const USER_LOOP_ROOT = () => path.join(os.homedir(), '.agents', LOOP_DIR_NAME);
const asNonEmptyString = (value) => {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
};
/**
* Split a `provider/model` string into its two parts. Splits on the first `/`
* so model ids containing a slash (e.g. `openai/gpt-5`) still resolve.
*/
const splitProviderModel = (value) => {
const raw = asNonEmptyString(value);
if (!raw) {
return null;
}
const separator = raw.indexOf('/');
if (separator <= 0 || separator === raw.length - 1) {
return null;
}
return {
providerId: raw.slice(0, separator).trim(),
modelId: raw.slice(separator + 1).trim(),
};
};
/**
* Parse one loop markdown file into a scheduled-task definition, or return
* null when the file is malformed. Malformed files are skipped with a warning
* and never prevent valid files from loading.
*/
export const parseLoopDefinition = (filePath) => {
let parsed;
try {
parsed = parseMdFile(filePath);
} catch (error) {
console.warn(`[loops] skipped malformed loop file ${filePath}:`, error?.message ?? error);
return null;
}
const frontmatter = parsed.frontmatter && typeof parsed.frontmatter === 'object'
? parsed.frontmatter
: {};
const name = asNonEmptyString(frontmatter.name);
if (!name) {
console.warn(`[loops] skipped ${filePath}: frontmatter "name" is required`);
return null;
}
const cron = asNonEmptyString(frontmatter.schedule);
if (!cron) {
console.warn(`[loops] skipped ${filePath}: frontmatter "schedule" (cron expression) is required`);
return null;
}
const prompt = asNonEmptyString(parsed.body);
if (!prompt) {
console.warn(`[loops] skipped ${filePath}: markdown body (the execution prompt) is required`);
return null;
}
const providerModel = splitProviderModel(frontmatter.model);
if (!providerModel) {
console.warn(`[loops] skipped ${filePath}: frontmatter "model" must be "provider/model"`);
return null;
}
const timezone = asNonEmptyString(frontmatter.timezone);
const agent = asNonEmptyString(frontmatter.agent);
return {
name,
enabled: typeof frontmatter.enabled === 'boolean' ? frontmatter.enabled : true,
schedule: {
kind: 'cron',
cron,
...(timezone ? { timezone } : {}),
},
execution: {
prompt,
providerID: providerModel.providerId,
modelID: providerModel.modelId,
...(agent ? { agent } : {}),
},
};
};
const walkLoopMdFiles = (rootDir) => {
if (!rootDir || !fs.existsSync(rootDir)) {
return [];
}
try {
return fs.readdirSync(rootDir, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
.map((entry) => path.join(rootDir, entry.name))
.sort();
} catch {
return [];
}
};
/**
* Discover loop files for a project: `~/.agents/loops/*.md` (user scope) plus
* `.agents/loops/*.md` in every ancestor of the project path up to the
* worktree root (project scope).
*/
export const discoverLoopFiles = (projectPath) => {
const files = [];
for (const filePath of walkLoopMdFiles(USER_LOOP_ROOT())) {
files.push({ filePath, scope: 'user' });
}
if (projectPath) {
const worktreeRoot = findWorktreeRoot(projectPath) || path.resolve(projectPath);
for (const ancestor of getAncestors(projectPath, worktreeRoot)) {
const root = path.join(ancestor, '.agents', LOOP_DIR_NAME);
for (const filePath of walkLoopMdFiles(root)) {
files.push({ filePath, scope: 'project' });
}
}
}
return files;
};
/**
* Discover and parse all loops for a project. Project-scope loops shadow
* user-scope loops with the same name; among project files the nearest
* ancestor wins. Malformed files are skipped with a warning.
*/
export const discoverLoops = (projectPath) => {
const byName = new Map();
for (const { filePath, scope } of discoverLoopFiles(projectPath)) {
const definition = parseLoopDefinition(filePath);
if (!definition) {
continue;
}
const existing = byName.get(definition.name);
if (existing && (existing.scope === 'project' || scope === 'user')) {
continue;
}
byName.set(definition.name, { scope, filePath, definition });
}
return [...byName.values()];
};
@@ -0,0 +1,335 @@
import { describe, expect, it, vi } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { mkdtemp, rm, writeFile, mkdir } from 'fs/promises';
import { parseLoopDefinition, discoverLoops, discoverLoopFiles } from './loops.js';
const createProject = async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'oc-loops-'));
const projectPath = path.join(tempRoot, 'repo');
await mkdir(projectPath, { recursive: true });
await mkdir(path.join(projectPath, '.git'), { recursive: true });
return {
projectPath,
cleanup: async () => {
await rm(tempRoot, { recursive: true, force: true });
},
};
};
const writeLoop = async (projectPath, fileName, content) => {
const dir = path.join(projectPath, '.agents', 'loops');
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, fileName), content, 'utf8');
};
describe('parseLoopDefinition', () => {
it('maps frontmatter and body to the scheduled-task definition shape', async () => {
const { projectPath, cleanup } = await createProject();
try {
await writeLoop(projectPath, 'digest.md', `---
name: daily-digest
schedule: "0 9 * * *"
enabled: true
model: anthropic/claude-sonnet-4-5
agent: plan
timezone: Europe/Kyiv
---
Summarize repository changes since yesterday.
`);
const definition = parseLoopDefinition(path.join(projectPath, '.agents', 'loops', 'digest.md'));
expect(definition).toEqual({
name: 'daily-digest',
enabled: true,
schedule: { kind: 'cron', cron: '0 9 * * *', timezone: 'Europe/Kyiv' },
execution: {
prompt: 'Summarize repository changes since yesterday.',
providerID: 'anthropic',
modelID: 'claude-sonnet-4-5',
agent: 'plan',
},
});
} finally {
await cleanup();
}
});
it('splits model ids containing a slash on the first separator', async () => {
const { projectPath, cleanup } = await createProject();
try {
const filePath = path.join(projectPath, 'loop.md');
await writeFile(filePath, `---
name: nested-model
schedule: "0 8 * * 1"
model: openai/gpt-5
---
Run weekly checks.
`, 'utf8');
const definition = parseLoopDefinition(filePath);
expect(definition.execution.providerID).toBe('openai');
expect(definition.execution.modelID).toBe('gpt-5');
expect(definition.enabled).toBe(true);
} finally {
await cleanup();
}
});
it('defaults enabled to true and omits optional fields', async () => {
const { projectPath, cleanup } = await createProject();
try {
const filePath = path.join(projectPath, 'loop.md');
await writeFile(filePath, `---
name: minimal
schedule: "*/30 * * * *"
model: openai/gpt-5
enabled: false
---
Run every half hour.
`, 'utf8');
const definition = parseLoopDefinition(filePath);
expect(definition.enabled).toBe(false);
expect(definition.schedule).toEqual({ kind: 'cron', cron: '*/30 * * * *' });
expect(definition.execution.agent).toBeUndefined();
} finally {
await cleanup();
}
});
it('returns null for files missing required frontmatter fields', async () => {
const { projectPath, cleanup } = await createProject();
try {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
try {
const noName = path.join(projectPath, 'noname.md');
await writeFile(noName, `---
schedule: "0 9 * * *"
model: openai/gpt-5
---
Prompt only.
`, 'utf8');
expect(parseLoopDefinition(noName)).toBeNull();
const noSchedule = path.join(projectPath, 'noschedule.md');
await writeFile(noSchedule, `---
name: no-schedule
model: openai/gpt-5
---
Prompt only.
`, 'utf8');
expect(parseLoopDefinition(noSchedule)).toBeNull();
const noModel = path.join(projectPath, 'nomodel.md');
await writeFile(noModel, `---
name: no-model
schedule: "0 9 * * *"
---
Prompt only.
`, 'utf8');
expect(parseLoopDefinition(noModel)).toBeNull();
const malformed = path.join(projectPath, 'malformed.md');
await writeFile(malformed, 'not a markdown frontmatter file at all', 'utf8');
expect(parseLoopDefinition(malformed)).toBeNull();
} finally {
warn.mockRestore();
}
} finally {
await cleanup();
}
});
it('treats a missing body as an invalid loop', async () => {
const { projectPath, cleanup } = await createProject();
try {
const filePath = path.join(projectPath, 'empty-body.md');
await writeFile(filePath, `---
name: empty-body
schedule: "0 9 * * *"
model: openai/gpt-5
---
`, 'utf8');
expect(parseLoopDefinition(filePath)).toBeNull();
} finally {
await cleanup();
}
});
});
describe('discoverLoops', () => {
it('discovers project loops and parses them', async () => {
const { projectPath, cleanup } = await createProject();
try {
await writeLoop(projectPath, 'digest.md', `---
name: daily-digest
schedule: "0 9 * * *"
model: openai/gpt-5
---
Summarize.
`);
const loops = discoverLoops(projectPath);
expect(loops).toHaveLength(1);
expect(loops[0].scope).toBe('project');
expect(loops[0].definition.name).toBe('daily-digest');
expect(loops[0].filePath.endsWith(path.join('.agents', 'loops', 'digest.md'))).toBe(true);
} finally {
await cleanup();
}
});
it('scans ancestor directories up to the worktree root', async () => {
const { projectPath, cleanup } = await createProject();
try {
// Worktree root contains the loop; the project directory is nested.
const nested = path.join(projectPath, 'src', 'nested');
await mkdir(nested, { recursive: true });
await writeLoop(projectPath, 'root-loop.md', `---
name: root-loop
schedule: "0 9 * * *"
model: openai/gpt-5
---
From the root.
`);
const loops = discoverLoops(nested);
expect(loops.map((loop) => loop.definition.name)).toEqual(['root-loop']);
expect(loops[0].scope).toBe('project');
} finally {
await cleanup();
}
});
it('discovers user-scope loops from ~/.agents/loops', async () => {
const { projectPath, cleanup } = await createProject();
const home = await mkdtemp(path.join(os.tmpdir(), 'oc-loops-home-'));
const userDir = path.join(home, '.agents', 'loops');
await mkdir(userDir, { recursive: true });
await writeFile(path.join(userDir, 'user-loop.md'), `---
name: user-loop
schedule: "0 7 * * *"
model: openai/gpt-5
---
User scope.
`, 'utf8');
const originalHome = os.homedir;
vi.spyOn(os, 'homedir').mockReturnValue(home);
try {
const loops = discoverLoops(projectPath);
expect(loops.map((loop) => loop.definition.name)).toEqual(['user-loop']);
expect(loops[0].scope).toBe('user');
} finally {
os.homedir = originalHome;
await rm(home, { recursive: true, force: true });
await cleanup();
}
});
it('lets project scope shadow user scope on name collision', async () => {
const { projectPath, cleanup } = await createProject();
const home = await mkdtemp(path.join(os.tmpdir(), 'oc-loops-home-'));
const userDir = path.join(home, '.agents', 'loops');
await mkdir(userDir, { recursive: true });
await writeFile(path.join(userDir, 'same-name.md'), `---
name: shared
schedule: "0 7 * * *"
model: openai/gpt-5
---
User version.
`, 'utf8');
await writeLoop(projectPath, 'same-name.md', `---
name: shared
schedule: "0 8 * * *"
model: anthropic/claude-sonnet-4-5
---
Project version.
`);
const originalHome = os.homedir;
vi.spyOn(os, 'homedir').mockReturnValue(home);
try {
const loops = discoverLoops(projectPath);
expect(loops).toHaveLength(1);
expect(loops[0].scope).toBe('project');
expect(loops[0].definition.execution.providerID).toBe('anthropic');
expect(loops[0].definition.schedule.cron).toBe('0 8 * * *');
} finally {
os.homedir = originalHome;
await rm(home, { recursive: true, force: true });
await cleanup();
}
});
it('skips malformed files without blocking valid ones', async () => {
const { projectPath, cleanup } = await createProject();
try {
await writeLoop(projectPath, 'bad.md', `---
name: bad
schedule: "0 9 * * *"
---
No model.
`);
await writeLoop(projectPath, 'good.md', `---
name: good
schedule: "0 9 * * *"
model: openai/gpt-5
---
Valid.
`);
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
try {
const loops = discoverLoops(projectPath);
expect(loops.map((loop) => loop.definition.name)).toEqual(['good']);
expect(warn).toHaveBeenCalled();
} finally {
warn.mockRestore();
}
} finally {
await cleanup();
}
});
it('returns an empty list when nothing exists', async () => {
const { projectPath, cleanup } = await createProject();
try {
expect(discoverLoops(projectPath)).toEqual([]);
} finally {
await cleanup();
}
});
it('lists raw loop files per scope without parsing', async () => {
const { projectPath, cleanup } = await createProject();
try {
await writeLoop(projectPath, 'one.md', `---
name: one
schedule: "0 9 * * *"
model: openai/gpt-5
---
One.
`);
await writeFile(path.join(projectPath, 'not-a-loop.txt'), 'ignore me', 'utf8');
const files = discoverLoopFiles(projectPath);
expect(files).toHaveLength(1);
expect(files[0].scope).toBe('project');
expect(files[0].filePath.endsWith(path.join('.agents', 'loops', 'one.md'))).toBe(true);
} finally {
await cleanup();
}
});
});
@@ -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) {