Every project-config write re-serialized normalized tasks, so a server that shares the config file but predates a field (goal, auto-accept) stripped it the first time any task ran. Untouched tasks now go back to disk verbatim, a state update swaps only `state`, and only a deliberately replaced task is serialized from the normalized shape.
855 lines
32 KiB
JavaScript
855 lines
32 KiB
JavaScript
import { describe, expect, it, vi } from 'vitest';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
import { mkdtemp, rm, readFile, writeFile } from 'fs/promises';
|
|
import { createProjectConfigRuntime } from './project-config.js';
|
|
|
|
const createRuntime = async () => {
|
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'oc-scheduled-project-config-'));
|
|
const runtime = createProjectConfigRuntime({
|
|
fsPromises: await import('fs/promises'),
|
|
path,
|
|
projectsDirPath: tempRoot,
|
|
createTaskID: () => 'task-fixed-id',
|
|
});
|
|
return {
|
|
runtime,
|
|
tempRoot,
|
|
cleanup: async () => {
|
|
await rm(tempRoot, { recursive: true, force: true });
|
|
},
|
|
};
|
|
};
|
|
|
|
describe('project-config runtime', () => {
|
|
it('creates and persists a scheduled task', async () => {
|
|
const { runtime, cleanup } = await createRuntime();
|
|
try {
|
|
const result = await runtime.upsertScheduledTask('project-test', {
|
|
name: 'Nightly digest',
|
|
enabled: true,
|
|
schedule: {
|
|
kind: 'daily',
|
|
time: '09:30',
|
|
timezone: 'UTC',
|
|
},
|
|
execution: {
|
|
prompt: 'Summarize repository changes',
|
|
providerID: 'openai',
|
|
modelID: 'gpt-4.1',
|
|
},
|
|
});
|
|
|
|
expect(result.created).toBe(true);
|
|
expect(result.task.id).toBe('task-fixed-id');
|
|
const reloaded = await runtime.listScheduledTasks('project-test');
|
|
expect(reloaded).toHaveLength(1);
|
|
expect(reloaded[0].name).toBe('Nightly digest');
|
|
expect(reloaded[0].schedule.timezone).toBe('UTC');
|
|
expect(reloaded[0].schedule.times).toEqual(['09:30']);
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
});
|
|
|
|
it('rejects invalid cron expressions', async () => {
|
|
const { runtime, cleanup } = await createRuntime();
|
|
try {
|
|
await expect(runtime.upsertScheduledTask('project-test', {
|
|
name: 'Invalid cron task',
|
|
enabled: true,
|
|
schedule: {
|
|
kind: 'cron',
|
|
cron: 'invalid cron',
|
|
timezone: 'UTC',
|
|
},
|
|
execution: {
|
|
prompt: 'Run checks',
|
|
providerID: 'openai',
|
|
modelID: 'gpt-4.1',
|
|
},
|
|
})).rejects.toThrow('schedule.cron is invalid');
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
});
|
|
|
|
it('preserves unknown project config keys when writing scheduled tasks', async () => {
|
|
const { runtime, cleanup } = await createRuntime();
|
|
try {
|
|
const projectID = 'path_preserve';
|
|
const filePath = path.join(runtime.resolveProjectConfigPath(projectID));
|
|
await writeFile(
|
|
filePath,
|
|
JSON.stringify({
|
|
projectNotes: 'hello notes',
|
|
projectTodos: [{ id: 't1', text: 'buy milk', completed: false, createdAt: 1 }],
|
|
projectActions: [{ id: 'a1', name: 'Run', command: 'bun run dev' }],
|
|
projectActionsPrimaryId: 'a1',
|
|
'setup-worktree': ['bun install'],
|
|
projectPlanFiles: [{ id: 'p1', path: '/tmp/plans/p1.md', createdAt: 2 }],
|
|
projectPath: '/tmp/demo',
|
|
}, null, 2),
|
|
'utf8',
|
|
);
|
|
|
|
await runtime.upsertScheduledTask(projectID, {
|
|
name: 'nightly',
|
|
enabled: true,
|
|
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
|
|
execution: { prompt: 'run', providerID: 'openai', modelID: 'gpt-4.1' },
|
|
});
|
|
|
|
const raw = JSON.parse(await readFile(filePath, 'utf8'));
|
|
expect(raw.projectNotes).toBe('hello notes');
|
|
expect(raw.projectTodos).toEqual([{ id: 't1', text: 'buy milk', completed: false, createdAt: 1 }]);
|
|
expect(raw.projectActions).toHaveLength(1);
|
|
expect(raw.projectActionsPrimaryId).toBe('a1');
|
|
expect(raw['setup-worktree']).toEqual(['bun install']);
|
|
expect(raw.projectPlanFiles).toEqual([{ id: 'p1', path: '/tmp/plans/p1.md', createdAt: 2 }]);
|
|
expect(raw.projectPath).toBe('/tmp/demo');
|
|
expect(raw.scheduledTasks).toHaveLength(1);
|
|
expect(raw.version).toBe(1);
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
});
|
|
|
|
it('preserves scheduled task state timestamps when listing tasks', async () => {
|
|
const { runtime, cleanup } = await createRuntime();
|
|
try {
|
|
const projectID = 'timestamp_preserve';
|
|
const filePath = path.join(runtime.resolveProjectConfigPath(projectID));
|
|
await writeFile(
|
|
filePath,
|
|
JSON.stringify({
|
|
scheduledTasks: [{
|
|
id: 'task-existing',
|
|
name: 'nightly',
|
|
enabled: true,
|
|
schedule: { kind: 'daily', times: ['09:00'], timezone: 'UTC' },
|
|
execution: { prompt: 'run', providerID: 'openai', modelID: 'gpt-4.1' },
|
|
state: { createdAt: 10, updatedAt: 20, lastStatus: 'idle' },
|
|
}],
|
|
}, null, 2),
|
|
'utf8',
|
|
);
|
|
|
|
const first = await runtime.listScheduledTasks(projectID);
|
|
const second = await runtime.listScheduledTasks(projectID);
|
|
|
|
expect(first[0].state.createdAt).toBe(10);
|
|
expect(first[0].state.updatedAt).toBe(20);
|
|
expect(second[0].state.updatedAt).toBe(20);
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
});
|
|
|
|
it('accepts one-time schedule with date and time', async () => {
|
|
const { runtime, cleanup } = await createRuntime();
|
|
try {
|
|
const result = await runtime.upsertScheduledTask('project-test', {
|
|
name: 'One-time review',
|
|
enabled: true,
|
|
schedule: {
|
|
kind: 'once',
|
|
date: '2026-04-20',
|
|
time: '13:45',
|
|
timezone: 'Europe/Kyiv',
|
|
},
|
|
execution: {
|
|
prompt: 'Create a release summary',
|
|
providerID: 'openai',
|
|
modelID: 'gpt-4.1',
|
|
},
|
|
});
|
|
|
|
expect(result.task.schedule.kind).toBe('once');
|
|
expect(result.task.schedule.date).toBe('2026-04-20');
|
|
expect(result.task.schedule.time).toBe('13:45');
|
|
expect(result.task.schedule.timezone).toBe('Europe/Kyiv');
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('project-config loop reconciliation', () => {
|
|
const loop = (name, overrides = {}) => ({
|
|
scope: 'project',
|
|
filePath: `/repo/.agents/loops/${name}.md`,
|
|
definition: {
|
|
name,
|
|
enabled: true,
|
|
schedule: { kind: 'cron', cron: '0 9 * * *', timezone: 'UTC' },
|
|
execution: {
|
|
prompt: `Loop prompt for ${name}`,
|
|
providerID: 'openai',
|
|
modelID: 'gpt-4.1',
|
|
},
|
|
...overrides,
|
|
},
|
|
});
|
|
|
|
it('creates tasks for discovered loops with deterministic ids', async () => {
|
|
const { runtime, cleanup } = await createRuntime();
|
|
try {
|
|
const tasks = await runtime.reconcileLoopTasks('project-test', [
|
|
loop('daily-digest'),
|
|
loop('weekly-report'),
|
|
]);
|
|
|
|
expect(tasks).toHaveLength(2);
|
|
const digest = tasks.find((task) => task.name === 'daily-digest');
|
|
expect(digest.id).toBe('loop:project:daily-digest');
|
|
expect(digest.schedule.cron).toBe('0 9 * * *');
|
|
expect(digest.execution.providerID).toBe('openai');
|
|
expect(digest.loopFile).toBe('/repo/.agents/loops/daily-digest.md');
|
|
|
|
const reloaded = await runtime.listScheduledTasks('project-test');
|
|
expect(reloaded).toHaveLength(2);
|
|
expect(reloaded[0].state.createdAt).toBeGreaterThan(0);
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
});
|
|
|
|
it('adopts an existing task by name, preserving id and state, and persists state across reconciles', 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' },
|
|
});
|
|
|
|
const first = await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]);
|
|
const adopted = first.find((task) => task.id === created.task.id);
|
|
expect(adopted).toBeDefined();
|
|
expect(adopted.id).toBe(created.task.id);
|
|
expect(adopted.name).toBe('daily-digest');
|
|
expect(adopted.schedule.kind).toBe('cron');
|
|
expect(adopted.schedule.cron).toBe('0 9 * * *');
|
|
expect(adopted.execution.prompt).toBe('Loop prompt for daily-digest');
|
|
expect(adopted.loopFile).toBe('/repo/.agents/loops/daily-digest.md');
|
|
|
|
const state = adopted.state;
|
|
await runtime.updateScheduledTaskState('project-test', adopted.id, {
|
|
nextRunAt: 123456,
|
|
lastRunAt: 111,
|
|
lastStatus: 'success',
|
|
});
|
|
|
|
const second = await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]);
|
|
const again = second.find((task) => task.id === created.task.id);
|
|
expect(again.id).toBe(created.task.id);
|
|
expect(again.state.nextRunAt).toBe(123456);
|
|
expect(again.state.lastRunAt).toBe(111);
|
|
expect(again.state.lastStatus).toBe('success');
|
|
expect(again.loopFile).toBe('/repo/.agents/loops/daily-digest.md');
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
});
|
|
|
|
it('unschedules a loop-sourced task when its file is removed', async () => {
|
|
const { runtime, cleanup } = await createRuntime();
|
|
try {
|
|
await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]);
|
|
const tasks = await runtime.reconcileLoopTasks('project-test', []);
|
|
|
|
expect(tasks).toHaveLength(0);
|
|
expect(await runtime.listScheduledTasks('project-test')).toHaveLength(0);
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
});
|
|
|
|
it('leaves JSON-configured tasks untouched when no loop matches', async () => {
|
|
const { runtime, cleanup } = await createRuntime();
|
|
try {
|
|
const created = await runtime.upsertScheduledTask('project-test', {
|
|
name: 'json-only',
|
|
enabled: true,
|
|
schedule: { kind: 'daily', time: '08:00', timezone: 'UTC' },
|
|
execution: { prompt: 'JSON prompt', providerID: 'openai', modelID: 'gpt-4.1' },
|
|
});
|
|
|
|
const tasks = await runtime.reconcileLoopTasks('project-test', [loop('loop-only')]);
|
|
|
|
expect(tasks).toHaveLength(2);
|
|
expect(tasks.find((task) => task.id === created.task.id)).toBeDefined();
|
|
expect(tasks.find((task) => task.name === 'loop-only')).toBeDefined();
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
});
|
|
|
|
it('does not remove a JSON task that merely shares a loop name after the loop is gone... keeps it when never adopted', async () => {
|
|
// A JSON task that was never driven by a loop file (no loopFile marker)
|
|
// must survive reconciles even when a loop with the same name existed
|
|
// only in a previous reconcile round — but once a loop adopted it, the
|
|
// file is authoritative and removing the file unschedules the task.
|
|
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' },
|
|
});
|
|
|
|
// First reconcile adopts the task (loopFile marker set).
|
|
await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]);
|
|
// Loop file removed -> task unscheduled.
|
|
const afterRemoval = await runtime.reconcileLoopTasks('project-test', []);
|
|
expect(afterRemoval.find((task) => task.id === created.task.id)).toBeUndefined();
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
});
|
|
|
|
it('skips invalid loop definitions without blocking valid ones', async () => {
|
|
const { runtime, cleanup } = await createRuntime();
|
|
try {
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
try {
|
|
const tasks = await runtime.reconcileLoopTasks('project-test', [
|
|
loop('bad-loop', { schedule: { kind: 'cron', cron: 'not a cron', timezone: 'UTC' } }),
|
|
loop('good-loop'),
|
|
]);
|
|
|
|
expect(tasks.map((task) => task.name)).toEqual(['good-loop']);
|
|
expect(warn).toHaveBeenCalled();
|
|
} finally {
|
|
warn.mockRestore();
|
|
}
|
|
} finally {
|
|
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();
|
|
}
|
|
});
|
|
|
|
describe('fields this build does not know', () => {
|
|
// Simulates a config written by a newer build (or a newer UI): the task
|
|
// carries execution and state fields normalization here has never heard of.
|
|
const seedForeignTask = async (runtime, tempRoot) => {
|
|
const created = await runtime.upsertScheduledTask('project-test', {
|
|
name: 'Nightly digest',
|
|
enabled: true,
|
|
schedule: { kind: 'daily', time: '09:30', timezone: 'UTC' },
|
|
execution: { prompt: 'Summarize', providerID: 'openai', modelID: 'gpt-4.1', goalEnabled: true },
|
|
});
|
|
const filePath = path.join(tempRoot, 'project-test.json');
|
|
const stored = JSON.parse(await readFile(filePath, 'utf8'));
|
|
stored.scheduledTasks[0].execution.futureExecutionField = 'keep me';
|
|
stored.scheduledTasks[0].state.futureStateField = 42;
|
|
stored.scheduledTasks[0].futureTopLevelField = true;
|
|
await writeFile(filePath, JSON.stringify(stored, null, 2), 'utf8');
|
|
return { id: created.task.id, filePath };
|
|
};
|
|
|
|
const readStoredTask = async (filePath, id) => {
|
|
const stored = JSON.parse(await readFile(filePath, 'utf8'));
|
|
return stored.scheduledTasks.find((task) => task.id === id);
|
|
};
|
|
|
|
it('survive a state update after a run, and the claim update', async () => {
|
|
const { runtime, tempRoot, cleanup } = await createRuntime();
|
|
try {
|
|
const { id, filePath } = await seedForeignTask(runtime, tempRoot);
|
|
|
|
await runtime.updateScheduledTaskState('project-test', id, { lastStatus: 'success', lastRunAt: 1000 });
|
|
let stored = await readStoredTask(filePath, id);
|
|
expect(stored.execution.futureExecutionField).toBe('keep me');
|
|
expect(stored.execution.goalEnabled).toBe(true);
|
|
expect(stored.futureTopLevelField).toBe(true);
|
|
expect(stored.state.lastStatus).toBe('success');
|
|
|
|
await runtime.updateScheduledTaskStateIf('project-test', id, () => true, { lastScheduledFor: 5000 });
|
|
stored = await readStoredTask(filePath, id);
|
|
expect(stored.execution.futureExecutionField).toBe('keep me');
|
|
expect(stored.state.lastScheduledFor).toBe(5000);
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
});
|
|
|
|
it('survive writes that replace or delete a different task, and a loop sync', async () => {
|
|
const { runtime, tempRoot, cleanup } = await createRuntime();
|
|
try {
|
|
const { id, filePath } = await seedForeignTask(runtime, tempRoot);
|
|
|
|
const other = await runtime.upsertScheduledTask('project-test', {
|
|
id: 'other-task',
|
|
name: 'Other',
|
|
enabled: true,
|
|
schedule: { kind: 'daily', time: '10:00', timezone: 'UTC' },
|
|
execution: { prompt: 'Other', providerID: 'openai', modelID: 'gpt-4.1' },
|
|
});
|
|
expect((await readStoredTask(filePath, id)).execution.futureExecutionField).toBe('keep me');
|
|
|
|
await runtime.deleteScheduledTask('project-test', other.task.id);
|
|
expect((await readStoredTask(filePath, id)).execution.futureExecutionField).toBe('keep me');
|
|
|
|
await runtime.reconcileLoopTasks('project-test', []);
|
|
expect((await readStoredTask(filePath, id)).execution.futureExecutionField).toBe('keep me');
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
});
|
|
|
|
it('are dropped only when the task itself is deliberately saved', async () => {
|
|
const { runtime, tempRoot, cleanup } = await createRuntime();
|
|
try {
|
|
const { id, filePath } = await seedForeignTask(runtime, tempRoot);
|
|
const [task] = await runtime.listScheduledTasks('project-test');
|
|
await runtime.upsertScheduledTask('project-test', { ...task, name: 'Renamed' });
|
|
const stored = await readStoredTask(filePath, id);
|
|
expect(stored.name).toBe('Renamed');
|
|
expect(stored.execution.futureExecutionField).toBeUndefined();
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
});
|
|
});
|
|
|
|
it('conditionally updates state only when the predicate passes (occurrence claim)', async () => {
|
|
const { runtime, cleanup } = await createRuntime();
|
|
try {
|
|
const created = await runtime.upsertScheduledTask('project-test', {
|
|
name: 'claim-me',
|
|
enabled: true,
|
|
schedule: { kind: 'daily', time: '15:00', timezone: 'UTC' },
|
|
execution: { prompt: 'Run once', providerID: 'openai', modelID: 'gpt-4.1' },
|
|
});
|
|
|
|
const scheduledFor = Date.UTC(2026, 0, 1, 15, 0, 0);
|
|
const first = await runtime.updateScheduledTaskStateIf(
|
|
'project-test',
|
|
created.task.id,
|
|
(task) => !Number.isFinite(task.state?.lastScheduledFor),
|
|
{
|
|
lastScheduledFor: scheduledFor,
|
|
lastStatus: 'running',
|
|
nextRunAt: scheduledFor + 86_400_000,
|
|
},
|
|
);
|
|
expect(first.updated).toBe(true);
|
|
expect(first.task.state.lastScheduledFor).toBe(scheduledFor);
|
|
|
|
const second = await runtime.updateScheduledTaskStateIf(
|
|
'project-test',
|
|
created.task.id,
|
|
(task) => task.state?.lastScheduledFor !== scheduledFor,
|
|
{
|
|
lastScheduledFor: scheduledFor,
|
|
lastStatus: 'running',
|
|
},
|
|
);
|
|
expect(second.updated).toBe(false);
|
|
expect(second.task.state.lastScheduledFor).toBe(scheduledFor);
|
|
|
|
const reloaded = await runtime.listScheduledTasks('project-test');
|
|
expect(reloaded[0].state.lastScheduledFor).toBe(scheduledFor);
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
});
|
|
|
|
it('serializes concurrent writes across two runtimes sharing a projects dir', async () => {
|
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'oc-project-lock-contention-'));
|
|
const fsPromises = await import('fs/promises');
|
|
try {
|
|
const runtimeA = createProjectConfigRuntime({
|
|
fsPromises,
|
|
path,
|
|
projectsDirPath: tempRoot,
|
|
createTaskID: () => 'shared-task',
|
|
});
|
|
const runtimeB = createProjectConfigRuntime({
|
|
fsPromises,
|
|
path,
|
|
projectsDirPath: tempRoot,
|
|
createTaskID: () => 'shared-task',
|
|
});
|
|
|
|
await runtimeA.upsertScheduledTask('project-lock', {
|
|
name: 'contended',
|
|
enabled: true,
|
|
schedule: { kind: 'daily', time: '15:00', timezone: 'UTC' },
|
|
execution: { prompt: 'Run', providerID: 'openai', modelID: 'gpt-4.1' },
|
|
});
|
|
|
|
await Promise.all([
|
|
runtimeA.updateScheduledTaskState('project-lock', 'shared-task', {
|
|
lastStatus: 'success',
|
|
lastRunAt: 100,
|
|
}),
|
|
runtimeB.updateScheduledTaskState('project-lock', 'shared-task', {
|
|
lastStatus: 'error',
|
|
lastRunAt: 200,
|
|
}),
|
|
]);
|
|
|
|
const tasks = await runtimeA.listScheduledTasks('project-lock');
|
|
expect(tasks).toHaveLength(1);
|
|
expect(['success', 'error']).toContain(tasks[0].state.lastStatus);
|
|
expect([100, 200]).toContain(tasks[0].state.lastRunAt);
|
|
|
|
const lockPath = `${runtimeA.resolveProjectConfigPath('project-lock')}.lock`;
|
|
await expect(fsPromises.access(lockPath)).rejects.toMatchObject({ code: 'ENOENT' });
|
|
} finally {
|
|
await rm(tempRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('recovers from a stale-by-age project config lock and cleans it up', async () => {
|
|
const { runtime, cleanup } = await createRuntime();
|
|
const fsPromises = await import('fs/promises');
|
|
try {
|
|
const projectID = 'stale-age';
|
|
const configPath = runtime.resolveProjectConfigPath(projectID);
|
|
const lockPath = `${configPath}.lock`;
|
|
await fsPromises.mkdir(path.dirname(configPath), { recursive: true });
|
|
await writeFile(lockPath, JSON.stringify({
|
|
pid: process.pid,
|
|
at: Date.now() - 120_000,
|
|
}));
|
|
|
|
const created = await runtime.upsertScheduledTask(projectID, {
|
|
name: 'after-stale',
|
|
enabled: true,
|
|
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
|
|
execution: { prompt: 'Run', providerID: 'openai', modelID: 'gpt-4.1' },
|
|
});
|
|
expect(created.created).toBe(true);
|
|
await expect(fsPromises.access(lockPath)).rejects.toMatchObject({ code: 'ENOENT' });
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
});
|
|
|
|
it('recovers from a stale-by-dead-pid project config lock', async () => {
|
|
const { runtime, cleanup } = await createRuntime();
|
|
const fsPromises = await import('fs/promises');
|
|
try {
|
|
const projectID = 'stale-pid';
|
|
const configPath = runtime.resolveProjectConfigPath(projectID);
|
|
const lockPath = `${configPath}.lock`;
|
|
await fsPromises.mkdir(path.dirname(configPath), { recursive: true });
|
|
// PID unlikely to exist; kill(pid, 0) should fail with ESRCH.
|
|
await writeFile(lockPath, JSON.stringify({
|
|
pid: 2_147_483_647,
|
|
at: Date.now(),
|
|
}));
|
|
|
|
const created = await runtime.upsertScheduledTask(projectID, {
|
|
name: 'after-dead-pid',
|
|
enabled: true,
|
|
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
|
|
execution: { prompt: 'Run', providerID: 'openai', modelID: 'gpt-4.1' },
|
|
});
|
|
expect(created.created).toBe(true);
|
|
await expect(fsPromises.access(lockPath)).rejects.toMatchObject({ code: 'ENOENT' });
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
});
|
|
|
|
it('release does not unlink a lock stolen by another holder', async () => {
|
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'oc-project-lock-own-'));
|
|
const realFs = await import('fs/promises');
|
|
let enteredCritical;
|
|
const entered = new Promise((resolve) => {
|
|
enteredCritical = resolve;
|
|
});
|
|
let resumeCritical;
|
|
const hold = new Promise((resolve) => {
|
|
resumeCritical = resolve;
|
|
});
|
|
|
|
const fsPromises = {
|
|
...realFs,
|
|
rename: async (from, to) => {
|
|
if (String(from).includes('.tmp-')) {
|
|
enteredCritical();
|
|
await hold;
|
|
}
|
|
return realFs.rename(from, to);
|
|
},
|
|
};
|
|
|
|
try {
|
|
const runtime = createProjectConfigRuntime({
|
|
fsPromises,
|
|
path,
|
|
projectsDirPath: tempRoot,
|
|
createTaskID: () => 'owned-task',
|
|
});
|
|
const projectID = 'lock-own';
|
|
const lockPath = `${runtime.resolveProjectConfigPath(projectID)}.lock`;
|
|
const stolenPid = process.pid + 1;
|
|
|
|
const upsertPromise = runtime.upsertScheduledTask(projectID, {
|
|
name: 'ownership',
|
|
enabled: true,
|
|
schedule: { kind: 'daily', time: '10:00', timezone: 'UTC' },
|
|
execution: { prompt: 'Run', providerID: 'openai', modelID: 'gpt-4.1' },
|
|
});
|
|
|
|
await entered;
|
|
await realFs.writeFile(lockPath, JSON.stringify({
|
|
pid: stolenPid,
|
|
at: Date.now(),
|
|
}));
|
|
resumeCritical();
|
|
await upsertPromise;
|
|
|
|
const raw = await realFs.readFile(lockPath, 'utf8');
|
|
expect(JSON.parse(raw).pid).toBe(stolenPid);
|
|
} finally {
|
|
await rm(tempRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('times out when a live lock holder never releases', async () => {
|
|
const { runtime, cleanup } = await createRuntime();
|
|
const fsPromises = await import('fs/promises');
|
|
try {
|
|
const projectID = 'lock-timeout';
|
|
const configPath = runtime.resolveProjectConfigPath(projectID);
|
|
const lockPath = `${configPath}.lock`;
|
|
await fsPromises.mkdir(path.dirname(configPath), { recursive: true });
|
|
// Current process is alive — acquire must wait then throw.
|
|
await writeFile(lockPath, JSON.stringify({
|
|
pid: process.pid,
|
|
at: Date.now(),
|
|
}));
|
|
|
|
await expect(runtime.upsertScheduledTask(projectID, {
|
|
name: 'blocked',
|
|
enabled: true,
|
|
schedule: { kind: 'daily', time: '11:00', timezone: 'UTC' },
|
|
execution: { prompt: 'Run', providerID: 'openai', modelID: 'gpt-4.1' },
|
|
})).rejects.toThrow(/timeout acquiring project config lock/);
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
}, 15_000);
|
|
|
|
it('releases the write chain after a lock timeout so a later write can complete', async () => {
|
|
const { runtime, cleanup } = await createRuntime();
|
|
const fsPromises = await import('fs/promises');
|
|
try {
|
|
const projectID = 'lock-timeout-recover';
|
|
const configPath = runtime.resolveProjectConfigPath(projectID);
|
|
const lockPath = `${configPath}.lock`;
|
|
await fsPromises.mkdir(path.dirname(configPath), { recursive: true });
|
|
await writeFile(lockPath, JSON.stringify({
|
|
pid: process.pid,
|
|
at: Date.now(),
|
|
}));
|
|
|
|
await expect(runtime.upsertScheduledTask(projectID, {
|
|
name: 'blocked-then-recover',
|
|
enabled: true,
|
|
schedule: { kind: 'daily', time: '11:00', timezone: 'UTC' },
|
|
execution: { prompt: 'Run', providerID: 'openai', modelID: 'gpt-4.1' },
|
|
})).rejects.toThrow(/timeout acquiring project config lock/);
|
|
|
|
// Remove the hostile lock. A wedged in-process chain would hang forever here.
|
|
await fsPromises.unlink(lockPath);
|
|
|
|
const created = await Promise.race([
|
|
runtime.upsertScheduledTask(projectID, {
|
|
name: 'after-timeout',
|
|
enabled: true,
|
|
schedule: { kind: 'daily', time: '12:00', timezone: 'UTC' },
|
|
execution: { prompt: 'Run after timeout', providerID: 'openai', modelID: 'gpt-4.1' },
|
|
}),
|
|
new Promise((_, reject) => {
|
|
setTimeout(() => reject(new Error('second write hung after lock timeout')), 3_000);
|
|
}),
|
|
]);
|
|
|
|
expect(created.created).toBe(true);
|
|
expect(created.task.name).toBe('after-timeout');
|
|
const listed = await runtime.listScheduledTasks(projectID);
|
|
expect(listed).toHaveLength(1);
|
|
expect(listed[0].name).toBe('after-timeout');
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
}, 20_000);
|
|
|
|
it('recovers from an unparseable lock using mtime age', async () => {
|
|
const { runtime, cleanup } = await createRuntime();
|
|
const fsPromises = await import('fs/promises');
|
|
try {
|
|
const projectID = 'stale-unparseable';
|
|
const configPath = runtime.resolveProjectConfigPath(projectID);
|
|
const lockPath = `${configPath}.lock`;
|
|
await fsPromises.mkdir(path.dirname(configPath), { recursive: true });
|
|
// Simulate crash between open(wx) and writeFile / partial payload.
|
|
await writeFile(lockPath, '{not-json');
|
|
const staleMtime = new Date(Date.now() - 120_000);
|
|
await fsPromises.utimes(lockPath, staleMtime, staleMtime);
|
|
|
|
const created = await runtime.upsertScheduledTask(projectID, {
|
|
name: 'after-unparseable',
|
|
enabled: true,
|
|
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
|
|
execution: { prompt: 'Run', providerID: 'openai', modelID: 'gpt-4.1' },
|
|
});
|
|
expect(created.created).toBe(true);
|
|
await expect(fsPromises.access(lockPath)).rejects.toMatchObject({ code: 'ENOENT' });
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
});
|
|
});
|