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:
makeittech
2026-08-06 09:28:31 +03:00
parent 2fcfe511a5
commit 359225d363
7 changed files with 334 additions and 28 deletions
@@ -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();
}
});
});