fix(tasks): sync markdown loops when listing tasks

This commit is contained in:
Bohdan Triapitsyn
2026-08-09 20:32:30 +03:00
parent a7f1d7d89e
commit ce6912fe40
4 changed files with 34 additions and 3 deletions
@@ -25,7 +25,7 @@ Check **Run as goal** to make the run pursue its prompt to completion instead of
## Loops: scheduled tasks as markdown files
A **loop** is a scheduled task defined as a portable markdown file you can commit to your repo. Drop a file into `.agents/loops/` and the task appears on the next sync — no dialog needed:
A **loop** is a scheduled task defined as a portable markdown file you can commit to your repo. Drop a file into `.agents/loops/` and open the Scheduled Tasks list to sync it — no server restart needed:
```markdown
---
@@ -21,10 +21,11 @@ Server-owned scheduled task runtime and routes for OpenChamber-only automation.
- `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)
- `syncProject` reconciles discovered loops with the persisted task list on every project sync (startup, task list load, task save/delete)
- `packages/web/server/lib/scheduled-tasks/routes.js`
- Scheduled task CRUD endpoints
- Listing tasks reconciles loop files first, so opening the Scheduled Tasks UI discovers file additions, edits, and removals without a server restart
- Manual run endpoint
- OpenChamber events SSE stream endpoint
@@ -50,7 +50,7 @@ export const createScheduledTaskService = (dependencies) => {
const list = async (projectID) => {
await findProjectByID(projectID);
return projectConfigRuntime.listScheduledTasks(projectID);
return scheduledTasksRuntime.syncProject(projectID);
};
const upsert = async (projectID, taskInput) => {
@@ -34,6 +34,36 @@ const loopTask = {
execution: { prompt: 'digest', providerID: 'openai', modelID: 'gpt-4.1' },
};
describe('scheduled-task service list', () => {
it('reconciles loop files before returning tasks', async () => {
const syncedTasks = [loopTask];
const { service, projectConfigRuntime, scheduledTasksRuntime } = createService({
scheduledTasksRuntime: {
syncProject: vi.fn(async () => syncedTasks),
},
});
await expect(service.list('project-test')).resolves.toBe(syncedTasks);
expect(scheduledTasksRuntime.syncProject).toHaveBeenCalledOnce();
expect(scheduledTasksRuntime.syncProject).toHaveBeenCalledWith('project-test');
expect(projectConfigRuntime.listScheduledTasks).not.toHaveBeenCalled();
});
it('surfaces reconciliation failure instead of returning a stale list', async () => {
const syncError = new Error('loop reconciliation failed');
const { service, projectConfigRuntime } = createService({
scheduledTasksRuntime: {
syncProject: vi.fn(async () => {
throw syncError;
}),
},
});
await expect(service.list('project-test')).rejects.toBe(syncError);
expect(projectConfigRuntime.listScheduledTasks).not.toHaveBeenCalled();
});
});
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-'));