fix(tasks): harden loop reconciliation against renames and malformed files
Review fixes for the markdown loop feature: - Loop-owned tasks now adopt by loop file path, not task name, so renaming a loop (frontmatter name or UI rename) renames the task in place instead of leaving a stale duplicate that keeps running the old definition; orphan duplicates of the same file are unscheduled. - Unparseable loop files are reported to the scheduler as definition:null entries: a task whose file still exists is kept with its last good definition, and only a genuinely removed file unschedules it. Transiently malformed files (mid-edit, bad merge) no longer delete tasks or their runtime state. - Adoption preserves UI-only execution fields (goalEnabled, goalTokenBudget, permissionAutoAccept, variant) that the portable format does not define. - DELETE on a loop-sourced task now returns 400 with guidance to remove the loop file, instead of being silently undone by the next reconcile. - Loops default to enabled: false; discovery of repository content never auto-executes scheduled sessions unless the file explicitly enables them. Regression tests for each fix; DOCUMENTATION.md updated.
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
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 without touching storage', async () => {
|
||||
const { service, projectConfigRuntime, scheduledTasksRuntime } = createService({
|
||||
projectConfigRuntime: {
|
||||
listScheduledTasks: vi.fn(async () => [loopTask]),
|
||||
},
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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