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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user