fix: recover transient coordinator dead letters
This commit is contained in:
+55
-1
@@ -10,7 +10,7 @@ const { decide } = require('../src/autonomy/policy');
|
|||||||
const { validatePaperIntent, createSimulator } = require('../src/autonomy/execution');
|
const { validatePaperIntent, createSimulator } = require('../src/autonomy/execution');
|
||||||
const { calculateOutcome } = require('../src/autonomy/outcomes');
|
const { calculateOutcome } = require('../src/autonomy/outcomes');
|
||||||
const { createOrderIntent } = require('../src/autonomy/orderIntents');
|
const { createOrderIntent } = require('../src/autonomy/orderIntents');
|
||||||
const { reconcileArchiveBatch, reconcileLiveBatch } = require('../workers/autonomyWorker');
|
const { enqueueCoordinatorEvent, reconcileArchiveBatch, reconcileLiveBatch } = require('../workers/autonomyWorker');
|
||||||
const { scheduleNext } = require('../workers/replayWorker');
|
const { scheduleNext } = require('../workers/replayWorker');
|
||||||
|
|
||||||
test('autonomy schema and leased jobs are restart-safe', () => {
|
test('autonomy schema and leased jobs are restart-safe', () => {
|
||||||
@@ -143,6 +143,60 @@ test('archive reconciliation prioritizes recent usable events and is bounded', (
|
|||||||
assert.equal(live.scanned, 1);
|
assert.equal(live.scanned, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('archive reconciliation recovers transient dead-lettered coordinator jobs', () => {
|
||||||
|
const intelligence = new Database(':memory:');
|
||||||
|
initAutonomySchema(intelligence);
|
||||||
|
enqueueJob(intelligence, {
|
||||||
|
jobType: 'coordinator_event', lane: 'historical', priority: 10, entityType: 'event', entityId: 99,
|
||||||
|
idempotencyKey: 'coordinator_event:99',
|
||||||
|
});
|
||||||
|
intelligence.prepare(`
|
||||||
|
UPDATE autonomy_jobs
|
||||||
|
SET status='dead_letter', attempts=5, last_error='TypeError: fetch failed'
|
||||||
|
WHERE idempotency_key='coordinator_event:99'
|
||||||
|
`).run();
|
||||||
|
|
||||||
|
const result = enqueueCoordinatorEvent(intelligence, {
|
||||||
|
event_id: 99,
|
||||||
|
ingested_at: new Date().toISOString(),
|
||||||
|
content: 'content',
|
||||||
|
has_embedding: 1,
|
||||||
|
});
|
||||||
|
assert.equal(result.recovered, true);
|
||||||
|
const job = intelligence.prepare("SELECT status, lane, priority, attempts, last_error FROM autonomy_jobs WHERE idempotency_key='coordinator_event:99'").get();
|
||||||
|
assert.equal(job.status, 'pending');
|
||||||
|
assert.equal(job.lane, 'live');
|
||||||
|
assert.equal(job.priority, 100);
|
||||||
|
assert.equal(job.attempts, 0);
|
||||||
|
assert.match(job.last_error, /Recovered transient coordinator failure/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('archive reconciliation leaves non-transient coordinator dead letters alone', () => {
|
||||||
|
const intelligence = new Database(':memory:');
|
||||||
|
initAutonomySchema(intelligence);
|
||||||
|
enqueueJob(intelligence, {
|
||||||
|
jobType: 'coordinator_event', lane: 'historical', priority: 10, entityType: 'event', entityId: 100,
|
||||||
|
idempotencyKey: 'coordinator_event:100',
|
||||||
|
});
|
||||||
|
intelligence.prepare(`
|
||||||
|
UPDATE autonomy_jobs
|
||||||
|
SET status='dead_letter', attempts=5, last_error='no tradable instruments are allowlisted'
|
||||||
|
WHERE idempotency_key='coordinator_event:100'
|
||||||
|
`).run();
|
||||||
|
|
||||||
|
const result = enqueueCoordinatorEvent(intelligence, {
|
||||||
|
event_id: 100,
|
||||||
|
ingested_at: new Date().toISOString(),
|
||||||
|
content: 'content',
|
||||||
|
has_embedding: 1,
|
||||||
|
});
|
||||||
|
assert.equal(result.recovered, false);
|
||||||
|
const job = intelligence.prepare("SELECT status, attempts, last_error FROM autonomy_jobs WHERE idempotency_key='coordinator_event:100'").get();
|
||||||
|
assert.equal(job.status, 'dead_letter');
|
||||||
|
assert.equal(job.attempts, 5);
|
||||||
|
assert.equal(job.last_error, 'no tradable instruments are allowlisted');
|
||||||
|
});
|
||||||
|
|
||||||
test('outcome calculation uses benchmark-relative return', () => {
|
test('outcome calculation uses benchmark-relative return', () => {
|
||||||
const outcome = calculateOutcome(
|
const outcome = calculateOutcome(
|
||||||
{ information_cutoff: '2026-01-02T00:00:00Z', horizon_days: 5, direction: 'positive' },
|
{ information_cutoff: '2026-01-02T00:00:00Z', horizon_days: 5, direction: 'positive' },
|
||||||
|
|||||||
+52
-16
@@ -5,6 +5,54 @@ const { enqueueJob, leaseNextJob, completeJob, failJob } = require('../src/auton
|
|||||||
|
|
||||||
function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
|
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) {
|
function reconcileArchiveBatch(archiveDb, intelligenceDb, batchSize = 250) {
|
||||||
const cursor = intelligenceDb.prepare("SELECT value FROM autonomy_cursors WHERE key = 'archive_reconcile'").get();
|
const cursor = intelligenceDb.prepare("SELECT value FROM autonomy_cursors WHERE key = 'archive_reconcile'").get();
|
||||||
const afterId = cursor ? cursor.value : 0;
|
const afterId = cursor ? cursor.value : 0;
|
||||||
@@ -24,17 +72,9 @@ function reconcileArchiveBatch(archiveDb, intelligenceDb, batchSize = 250) {
|
|||||||
}
|
}
|
||||||
const enqueue = intelligenceDb.transaction(() => {
|
const enqueue = intelligenceDb.transaction(() => {
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const isLive = row.ingested_at && Date.now() - Date.parse(row.ingested_at) <= 48 * 60 * 60 * 1000;
|
|
||||||
const readyForIntelligence = row.event_id && row.content && row.has_embedding;
|
const readyForIntelligence = row.event_id && row.content && row.has_embedding;
|
||||||
if (readyForIntelligence) {
|
if (readyForIntelligence) {
|
||||||
enqueueJob(intelligenceDb, {
|
enqueueCoordinatorEvent(intelligenceDb, row);
|
||||||
jobType: 'coordinator_event',
|
|
||||||
lane: isLive ? 'live' : 'historical',
|
|
||||||
priority: isLive ? 100 : 10,
|
|
||||||
entityType: 'event',
|
|
||||||
entityId: row.event_id,
|
|
||||||
idempotencyKey: `coordinator_event:${row.event_id}`,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
intelligenceDb.prepare(`
|
intelligenceDb.prepare(`
|
||||||
@@ -57,12 +97,8 @@ function reconcileLiveBatch(archiveDb, intelligenceDb, batchSize = 250) {
|
|||||||
let queued = 0;
|
let queued = 0;
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (!row.event_id || !row.content || !row.has_embedding) continue;
|
if (!row.event_id || !row.content || !row.has_embedding) continue;
|
||||||
const result = enqueueJob(intelligenceDb, {
|
const result = enqueueCoordinatorEvent(intelligenceDb, row);
|
||||||
jobType: 'coordinator_event', lane: 'live', priority: 100,
|
if (result.inserted || result.recovered) queued++;
|
||||||
entityType: 'event', entityId: row.event_id,
|
|
||||||
idempotencyKey: `coordinator_event:${row.event_id}`,
|
|
||||||
});
|
|
||||||
if (result.inserted) queued++;
|
|
||||||
}
|
}
|
||||||
return { scanned: rows.length, queued };
|
return { scanned: rows.length, queued };
|
||||||
}
|
}
|
||||||
@@ -98,4 +134,4 @@ async function runAutonomyWorker({ archivePath, intelligencePath, workerId = `au
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { reconcileArchiveBatch, reconcileLiveBatch, runAutonomyWorker };
|
module.exports = { enqueueCoordinatorEvent, reconcileArchiveBatch, reconcileLiveBatch, runAutonomyWorker };
|
||||||
|
|||||||
Reference in New Issue
Block a user