diff --git a/packages/web/server/lib/projects/project-config.js b/packages/web/server/lib/projects/project-config.js index 453d487f..54166695 100644 --- a/packages/web/server/lib/projects/project-config.js +++ b/packages/web/server/lib/projects/project-config.js @@ -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::` 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); } diff --git a/packages/web/server/lib/projects/project-config.test.js b/packages/web/server/lib/projects/project-config.test.js index 38122c3f..408866c8 100644 --- a/packages/web/server/lib/projects/project-config.test.js +++ b/packages/web/server/lib/projects/project-config.test.js @@ -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(); + } + }); }); diff --git a/packages/web/server/lib/scheduled-tasks/DOCUMENTATION.md b/packages/web/server/lib/scheduled-tasks/DOCUMENTATION.md index 16206a82..5bdba849 100644 --- a/packages/web/server/lib/scheduled-tasks/DOCUMENTATION.md +++ b/packages/web/server/lib/scheduled-tasks/DOCUMENTATION.md @@ -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::` id so runtime state survives restarts. + `loop::` 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) diff --git a/packages/web/server/lib/scheduled-tasks/loops.js b/packages/web/server/lib/scheduled-tasks/loops.js index 2dbcf4c2..1fd71be7 100644 --- a/packages/web/server/lib/scheduled-tasks/loops.js +++ b/packages/web/server/lib/scheduled-tasks/loops.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; }; diff --git a/packages/web/server/lib/scheduled-tasks/loops.test.js b/packages/web/server/lib/scheduled-tasks/loops.test.js index ac204392..7281adda 100644 --- a/packages/web/server/lib/scheduled-tasks/loops.test.js +++ b/packages/web/server/lib/scheduled-tasks/loops.test.js @@ -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(); diff --git a/packages/web/server/lib/scheduled-tasks/service.js b/packages/web/server/lib/scheduled-tasks/service.js index 7d44f4e8..33d69f7d 100644 --- a/packages/web/server/lib/scheduled-tasks/service.js +++ b/packages/web/server/lib/scheduled-tasks/service.js @@ -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); diff --git a/packages/web/server/lib/scheduled-tasks/service.test.js b/packages/web/server/lib/scheduled-tasks/service.test.js new file mode 100644 index 00000000..07d6bf70 --- /dev/null +++ b/packages/web/server/lib/scheduled-tasks/service.test.js @@ -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); + }); +});