diff --git a/packages/core/src/queues.ts b/packages/core/src/queues.ts index 61667133..e50011b7 100644 --- a/packages/core/src/queues.ts +++ b/packages/core/src/queues.ts @@ -11,6 +11,7 @@ import { MessageJobData, ResponseJobData } from './types'; const QUEUE_DB_PATH = path.join(TINYCLAW_HOME, 'tinyclaw.db'); const MAX_RETRIES = 5; +const MAX_MANUAL_RETRIES = 3; let db: Database.Database | null = null; export const queueEvents = new EventEmitter(); @@ -29,7 +30,9 @@ export function initQueueDb(): void { message TEXT NOT NULL, agent TEXT, files TEXT, conversation_id TEXT, from_agent TEXT, status TEXT NOT NULL DEFAULT 'pending', - retry_count INTEGER NOT NULL DEFAULT 0, last_error TEXT, + retry_count INTEGER NOT NULL DEFAULT 0, + manual_retry_count INTEGER NOT NULL DEFAULT 0, + last_error TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS responses ( @@ -67,6 +70,12 @@ export function initQueueDb(): void { if (!cols.some(c => c.name === 'metadata')) { db.exec('ALTER TABLE responses ADD COLUMN metadata TEXT'); } + + // Migrate: add manual_retry_count to messages if missing (for existing databases) + const msgCols = db.prepare("PRAGMA table_info(messages)").all() as { name: string }[]; + if (!msgCols.some(c => c.name === 'manual_retry_count')) { + db.exec('ALTER TABLE messages ADD COLUMN manual_retry_count INTEGER NOT NULL DEFAULT 0'); + } } function getDb(): Database.Database { @@ -172,8 +181,15 @@ export function getDeadMessages(): any[] { return getDb().prepare(`SELECT * FROM messages WHERE status='dead' ORDER BY updated_at DESC`).all(); } -export function retryDeadMessage(rowId: number): boolean { - return getDb().prepare(`UPDATE messages SET status='pending',retry_count=0,updated_at=? WHERE id=? AND status='dead'`).run(Date.now(), rowId).changes > 0; +export function retryDeadMessage(rowId: number): boolean | 'cap_exceeded' { + const msg = getDb().prepare('SELECT manual_retry_count FROM messages WHERE id=? AND status=\'dead\'').get(rowId) as { manual_retry_count: number } | undefined; + if (!msg) return false; + if (msg.manual_retry_count >= MAX_MANUAL_RETRIES) return 'cap_exceeded'; + // Set retry_count to MAX_RETRIES - 1 so the message gets exactly one more + // automatic attempt before going dead again, rather than a full reset to 0. + return getDb().prepare( + `UPDATE messages SET status='pending', retry_count=?, manual_retry_count=manual_retry_count+1, updated_at=? WHERE id=? AND status='dead'` + ).run(MAX_RETRIES - 1, Date.now(), rowId).changes > 0; } export function deleteDeadMessage(rowId: number): boolean { diff --git a/packages/server/src/routes/queue.ts b/packages/server/src/routes/queue.ts index bc60c7ab..badf6559 100644 --- a/packages/server/src/routes/queue.ts +++ b/packages/server/src/routes/queue.ts @@ -110,6 +110,8 @@ export function createQueueRoutes(conversations: Map) { }, failedReason: m.last_error, attemptsMade: m.retry_count, + manualRetriesUsed: m.manual_retry_count, + manualRetriesRemaining: Math.max(0, 3 - m.manual_retry_count), timestamp: m.created_at, }))); }); @@ -117,8 +119,9 @@ export function createQueueRoutes(conversations: Map) { // POST /api/queue/dead/:id/retry app.post('/api/queue/dead/:id/retry', (c) => { const id = parseInt(c.req.param('id'), 10); - const ok = retryDeadMessage(id); - if (!ok) return c.json({ error: 'dead message not found' }, 404); + const result = retryDeadMessage(id); + if (result === false) return c.json({ error: 'dead message not found' }, 404); + if (result === 'cap_exceeded') return c.json({ error: `Manual retry limit (${3}) reached for this message. Delete it if you want to discard it.` }, 429); log('INFO', `[API] Dead message ${id} retried`); return c.json({ ok: true }); });