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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user