automagik-genie
Version:
Self-evolving AI agent orchestration framework with Model Context Protocol support
154 lines (153 loc) • 6.41 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.TaskService = void 0;
const fs_1 = __importDefault(require("fs"));
const promises_1 = __importDefault(require("fs/promises"));
const path_1 = __importDefault(require("path"));
const task_store_1 = require("../task-store");
class TaskService {
constructor(options) {
this.paths = options.paths;
this.loadConfig = options.loadConfig;
this.defaults = options.defaults;
this.onWarning = options.onWarning;
}
load(callbacks = {}) {
const mergedCallbacks = {
onWarning: callbacks.onWarning || this.onWarning
};
return (0, task_store_1.loadTasks)(this.paths, this.loadConfig, this.defaults, mergedCallbacks);
}
async save(store) {
const targetFile = this.paths.tasksFile;
if (!targetFile) {
return { store };
}
await this.ensureFile(targetFile);
// Native file locking with retry logic
return await this.retryWithLock(async () => {
// TWIN FIX #3: Reload fresh disk state immediately before merge to prevent rollback
const diskStore = (0, task_store_1.loadTasks)(this.paths, this.loadConfig, this.defaults, {
onWarning: this.onWarning
});
const merged = this.mergeStores(diskStore, store);
// TWIN FIX #1: Atomic write via temp file + rename to prevent partial reads
const tempFile = targetFile + '.tmp';
await promises_1.default.writeFile(tempFile, JSON.stringify(merged, null, 2), 'utf8');
// Ensure data is flushed to disk before rename
const fd = await promises_1.default.open(tempFile, 'r+');
await fd.sync();
await fd.close();
// Atomic rename - readers will only see complete JSON
await promises_1.default.rename(tempFile, targetFile);
return { store: merged };
});
}
async withLock(fn) {
const baseFile = this.paths.tasksFile;
if (!baseFile) {
throw new Error('TaskService: No tasks file configured for locking');
}
const lockPath = baseFile + '.lock';
let handle = null;
try {
// TWIN FIX #2: Detect and reclaim stale locks from crashed processes
await this.reclaimStaleLock(lockPath);
// Exclusive lock - will retry if locked (wx = write exclusive, fails if exists)
handle = await promises_1.default.open(lockPath, 'wx');
// Write PID/timestamp for stale lock detection
const lockInfo = JSON.stringify({
pid: process.pid,
timestamp: Date.now(),
host: require('os').hostname()
});
await promises_1.default.writeFile(lockPath, lockInfo, 'utf8');
const result = await fn();
return result;
}
finally {
if (handle) {
await handle.close();
await promises_1.default.unlink(lockPath).catch(() => { });
}
}
}
async reclaimStaleLock(lockPath) {
try {
const stat = await promises_1.default.stat(lockPath);
const age = Date.now() - stat.mtimeMs;
const STALE_THRESHOLD = 30000; // 30 seconds
if (age > STALE_THRESHOLD) {
// Try to read lock info to identify stale process
let lockInfo = {};
try {
const content = await promises_1.default.readFile(lockPath, 'utf8');
lockInfo = JSON.parse(content);
}
catch {
// Lock file exists but unreadable - likely corrupted, reclaim it
}
// Check if process is still running (won't throw on invalid PID)
if (lockInfo.pid) {
try {
process.kill(lockInfo.pid, 0); // Signal 0 = check if process exists
// Process exists, don't reclaim (false positive on PID reuse)
return;
}
catch {
// Process doesn't exist, safe to reclaim
}
}
// Stale lock detected - reclaim it
await promises_1.default.unlink(lockPath);
if (this.onWarning) {
this.onWarning(`Reclaimed stale lock file (age: ${Math.round(age / 1000)}s, pid: ${lockInfo.pid || 'unknown'})`);
}
}
}
catch (err) {
if (err.code !== 'ENOENT') {
// Ignore ENOENT (lock doesn't exist yet), throw other errors
throw err;
}
}
}
async retryWithLock(fn, maxRetries = 10) {
for (let i = 0; i < maxRetries; i++) {
try {
return await this.withLock(fn);
}
catch (err) {
if (err.code === 'EEXIST' && i < maxRetries - 1) {
// Lock file exists, wait and retry
await new Promise(resolve => setTimeout(resolve, 50 + Math.random() * 100));
continue;
}
throw err;
}
}
throw new Error('TaskService: Lock acquisition timeout after ' + maxRetries + ' retries');
}
async ensureFile(target) {
const dir = path_1.default.dirname(target);
await promises_1.default.mkdir(dir, { recursive: true });
if (!fs_1.default.existsSync(target)) {
const initial = { version: 4, sessions: {} };
await promises_1.default.writeFile(target, JSON.stringify(initial, null, 2), 'utf8');
}
}
mergeStores(base, incoming) {
const merged = {
version: incoming.version ?? base.version ?? 2,
sessions: { ...base.sessions }
};
Object.entries(incoming.sessions || {}).forEach(([taskId, entry]) => {
merged.sessions[taskId] = { ...(base.sessions?.[taskId] || {}), ...entry };
});
return merged;
}
}
exports.TaskService = TaskService;