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