From 85c3aab54c6c13f71da8264d16aeea395e1c9b90 Mon Sep 17 00:00:00 2001 From: Josh Centers Date: Mon, 16 Mar 2026 02:18:41 +0100 Subject: [PATCH] fix(queue): cap dead-letter manual retries to prevent infinite retry loops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit retryDeadMessage() was resetting retry_count to 0 on every manual retry, giving each retried message a full 5 new attempts. An agent with access to the tinyclaw-admin skill could call POST /api/queue/dead/:id/retry indefinitely, creating an infinite token burn loop on any persistently failing message. Changes: - Add MAX_MANUAL_RETRIES = 3 constant - Add manual_retry_count column to messages table (with migration for existing databases) - retryDeadMessage() returns 'cap_exceeded' after 3 manual retries instead of resetting — no bypass - On retry, set retry_count = MAX_RETRIES - 1 (not 0) so the message gets exactly one more automatic attempt rather than five - POST /api/queue/dead/:id/retry returns HTTP 429 when cap is exceeded - GET /api/queue/dead now includes manualRetriesUsed and manualRetriesRemaining in the response Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/queues.ts | 22 +++++++++++++++++++--- packages/server/src/routes/queue.ts | 7 +++++-- 2 files changed, 24 insertions(+), 5 deletions(-) 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 }); });