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(); } }); });