Files
Duriin-API/workers/autonomyWorker.js
T

138 lines
5.4 KiB
JavaScript

const os = require('os');
const { openRuntimeDb } = require('../src/db/runtime');
const { initAutonomySchema } = require('../src/autonomy/schema');
const { enqueueJob, leaseNextJob, completeJob, failJob } = require('../src/autonomy/jobs');
function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
function isTransientCoordinatorFailure(error) {
const value = String(error || '').toLowerCase();
return value.includes('fetch failed')
|| value.includes('network')
|| value.includes('timeout')
|| value.includes('before response')
|| /\b(408|429|5\d\d)\b/.test(value);
}
function enqueueCoordinatorEvent(intelligenceDb, row) {
const isLive = row.ingested_at && Date.now() - Date.parse(row.ingested_at) <= 48 * 60 * 60 * 1000;
const lane = isLive ? 'live' : 'historical';
const priority = isLive ? 100 : 10;
const idempotencyKey = `coordinator_event:${row.event_id}`;
const result = enqueueJob(intelligenceDb, {
jobType: 'coordinator_event',
lane,
priority,
entityType: 'event',
entityId: row.event_id,
idempotencyKey,
});
if (result.inserted) return { inserted: true, recovered: false };
const existing = intelligenceDb.prepare(`
SELECT id, status, last_error
FROM autonomy_jobs
WHERE idempotency_key = ? AND job_type = 'coordinator_event'
`).get(idempotencyKey);
if (!existing || existing.status !== 'dead_letter' || !isTransientCoordinatorFailure(existing.last_error)) {
return { inserted: false, recovered: false };
}
const recovered = intelligenceDb.prepare(`
UPDATE autonomy_jobs
SET status = 'pending',
lane = ?,
priority = ?,
attempts = 0,
available_at = datetime('now'),
leased_by = NULL,
lease_expires_at = NULL,
last_error = ?
WHERE id = ? AND status = 'dead_letter'
`).run(lane, priority, `Recovered transient coordinator failure: ${existing.last_error || 'unknown error'}`, existing.id);
return { inserted: false, recovered: recovered.changes > 0 };
}
function reconcileArchiveBatch(archiveDb, intelligenceDb, batchSize = 250) {
const cursor = intelligenceDb.prepare("SELECT value FROM autonomy_cursors WHERE key = 'archive_reconcile'").get();
const afterId = cursor ? cursor.value : 0;
const rows = archiveDb.prepare(`
SELECT id, event_id, ingested_at, content, has_embedding
FROM articles
WHERE id > ?
ORDER BY id ASC
LIMIT ?
`).all(afterId, batchSize);
if (!rows.length) {
intelligenceDb.prepare(`
INSERT INTO autonomy_cursors(key, value) VALUES ('archive_reconcile', 0)
ON CONFLICT(key) DO UPDATE SET value = 0, updated_at = datetime('now')
`).run();
return { scanned: 0, nextCursor: 0, reset: true };
}
const enqueue = intelligenceDb.transaction(() => {
for (const row of rows) {
const readyForIntelligence = row.event_id && row.content && row.has_embedding;
if (readyForIntelligence) {
enqueueCoordinatorEvent(intelligenceDb, row);
}
}
intelligenceDb.prepare(`
INSERT INTO autonomy_cursors(key, value) VALUES ('archive_reconcile', ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')
`).run(rows[rows.length - 1].id);
});
enqueue();
return { scanned: rows.length, nextCursor: rows[rows.length - 1].id, reset: false };
}
function reconcileLiveBatch(archiveDb, intelligenceDb, batchSize = 250) {
const rows = archiveDb.prepare(`
SELECT id, event_id, ingested_at, content, has_embedding
FROM articles
WHERE ingested_at >= datetime('now', '-48 hours')
ORDER BY ingested_at DESC, id DESC
LIMIT ?
`).all(batchSize);
let queued = 0;
for (const row of rows) {
if (!row.event_id || !row.content || !row.has_embedding) continue;
const result = enqueueCoordinatorEvent(intelligenceDb, row);
if (result.inserted || result.recovered) queued++;
}
return { scanned: rows.length, queued };
}
async function runAutonomyWorker({ archivePath, intelligencePath, workerId = `autonomy-${os.hostname()}-${process.pid}`, pollMs = 1000 } = {}) {
const archiveDb = openRuntimeDb(archivePath, { schema: 'archive', readonly: true });
const intelligenceDb = openRuntimeDb(intelligencePath, { schema: 'intelligence' });
intelligenceDb.pragma('journal_mode = WAL');
intelligenceDb.pragma('busy_timeout = 5000');
initAutonomySchema(intelligenceDb);
while (true) {
// This worker owns maintenance reconciliation only. Without the type filter it
// can lease coordinator_event jobs and complete them without analysis.
const job = leaseNextJob(intelligenceDb, workerId, 120, ['reconcile_archive']);
if (!job) { await sleep(pollMs); continue; }
try {
if (job.job_type === 'reconcile_archive') {
reconcileLiveBatch(archiveDb, intelligenceDb);
reconcileArchiveBatch(archiveDb, intelligenceDb);
// Keep the reconciler alive as a bounded maintenance loop.
enqueueJob(intelligenceDb, {
jobType: 'reconcile_archive', lane: 'maintenance', priority: 100,
entityType: 'archive', entityId: 'archive',
idempotencyKey: `reconcile_archive:${Date.now()}`,
});
}
completeJob(intelligenceDb, job.id, workerId);
} catch (error) {
failJob(intelligenceDb, job.id, workerId, error);
}
await sleep(pollMs);
}
}
module.exports = { enqueueCoordinatorEvent, reconcileArchiveBatch, reconcileLiveBatch, runAutonomyWorker };