138 lines
5.4 KiB
JavaScript
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 };
|