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
@@ -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;
};