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:
@@ -570,18 +570,27 @@ export const createProjectConfigRuntime = (deps) => {
|
||||
* 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).
|
||||
* - 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 only parsed definitions, and normalization
|
||||
* failures here are isolated per loop.
|
||||
* 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 () => {
|
||||
@@ -591,14 +600,19 @@ export const createProjectConfigRuntime = (deps) => {
|
||||
|
||||
const activeLoopFilePaths = new Set();
|
||||
const pendingLoops = new Map();
|
||||
const loopsByPath = new Map();
|
||||
for (const loop of loops) {
|
||||
if (!loop || !loop.definition || typeof loop.definition !== 'object') {
|
||||
if (!loop || typeof loop.filePath !== 'string' || !loop.filePath) {
|
||||
continue;
|
||||
}
|
||||
activeLoopFilePaths.add(loop.filePath);
|
||||
pendingLoops.set(loop.definition.name, loop);
|
||||
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)) {
|
||||
@@ -606,11 +620,22 @@ export const createProjectConfigRuntime = (deps) => {
|
||||
continue;
|
||||
}
|
||||
|
||||
const loop = pendingLoops.get(task.name);
|
||||
// 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, loopFile: loop.filePath },
|
||||
{
|
||||
...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,
|
||||
@@ -621,6 +646,10 @@ export const createProjectConfigRuntime = (deps) => {
|
||||
);
|
||||
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);
|
||||
@@ -628,6 +657,12 @@ export const createProjectConfigRuntime = (deps) => {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -329,4 +329,146 @@ describe('project-config loop reconciliation', () => {
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,7 +50,7 @@ Field mapping (model: `packages/ui/src/lib/scheduledTasksApi.ts`):
|
||||
|---|---|
|
||||
| `name` | `name` (required) |
|
||||
| `schedule` | `schedule.kind: "cron"` + `schedule.cron` (required, cron-only in the portable format) |
|
||||
| `enabled` | `enabled` (default `true`) |
|
||||
| `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) |
|
||||
@@ -67,24 +67,37 @@ the project config state store.
|
||||
`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).
|
||||
- **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.
|
||||
`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 skipped with a warning and never block valid loops in the
|
||||
same or other scopes.
|
||||
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.
|
||||
definition (including `enabled`). Use `enabled: false` in the file to
|
||||
disable. Deleting a loop-sourced task through the API is rejected with a 400 —
|
||||
the loop file is the removal surface.
|
||||
|
||||
## Public exports (runtime.js)
|
||||
|
||||
|
||||
@@ -21,7 +21,9 @@
|
||||
* 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)
|
||||
* 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)
|
||||
@@ -116,7 +118,7 @@ export const parseLoopDefinition = (filePath) => {
|
||||
|
||||
return {
|
||||
name,
|
||||
enabled: typeof frontmatter.enabled === 'boolean' ? frontmatter.enabled : true,
|
||||
enabled: typeof frontmatter.enabled === 'boolean' ? frontmatter.enabled : false,
|
||||
schedule: {
|
||||
kind: 'cron',
|
||||
cron,
|
||||
@@ -170,13 +172,21 @@ export const discoverLoopFiles = (projectPath) => {
|
||||
/**
|
||||
* 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.
|
||||
* 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);
|
||||
@@ -185,5 +195,8 @@ export const discoverLoops = (projectPath) => {
|
||||
}
|
||||
byName.set(definition.name, { scope, filePath, definition });
|
||||
}
|
||||
return [...byName.values()];
|
||||
for (const entry of byName.values()) {
|
||||
loops.push(entry);
|
||||
}
|
||||
return loops;
|
||||
};
|
||||
|
||||
@@ -73,13 +73,13 @@ Run weekly checks.
|
||||
|
||||
expect(definition.execution.providerID).toBe('openai');
|
||||
expect(definition.execution.modelID).toBe('gpt-5');
|
||||
expect(definition.enabled).toBe(true);
|
||||
expect(definition.enabled).toBe(false);
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('defaults enabled to true and omits optional fields', async () => {
|
||||
it('defaults enabled to false and omits optional fields', async () => {
|
||||
const { projectPath, cleanup } = await createProject();
|
||||
try {
|
||||
const filePath = path.join(projectPath, 'loop.md');
|
||||
@@ -87,13 +87,14 @@ Run weekly checks.
|
||||
name: minimal
|
||||
schedule: "*/30 * * * *"
|
||||
model: openai/gpt-5
|
||||
enabled: false
|
||||
---
|
||||
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();
|
||||
@@ -102,6 +103,25 @@ Run every half hour.
|
||||
}
|
||||
});
|
||||
|
||||
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 {
|
||||
@@ -272,7 +292,7 @@ Project version.
|
||||
}
|
||||
});
|
||||
|
||||
it('skips malformed files without blocking valid ones', async () => {
|
||||
it('reports malformed files as unparsed entries without blocking valid ones', async () => {
|
||||
const { projectPath, cleanup } = await createProject();
|
||||
try {
|
||||
await writeLoop(projectPath, 'bad.md', `---
|
||||
@@ -292,7 +312,14 @@ Valid.
|
||||
try {
|
||||
const loops = discoverLoops(projectPath);
|
||||
|
||||
expect(loops.map((loop) => loop.definition.name)).toEqual(['good']);
|
||||
// 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();
|
||||
|
||||
@@ -78,6 +78,17 @@ 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) {
|
||||
// Loop tasks are owned by their `.agents/loops` markdown file: deleting
|
||||
// the JSON row would be silently undone by the next reconcile. The file
|
||||
// itself is the removal surface.
|
||||
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,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