···11import crypto from 'node:crypto'
22+import { isTransientTransport } from '#server/utils/git-wire/errors'
23import { dispatch } from '#server/utils/job-handlers'
34import { claim, cleanupOldJobs, complete, fail } from '#server/utils/queue'
45···9192 }
9293 catch (err) {
9394 failed++
9595+ // A flaky knot resets connections for minutes at a time; a push to a
9696+ // real branch must survive that and land once the knot recovers, not
9797+ // be retired to `failed` after 8 unlucky attempts. Keep transient
9898+ // transport failures (and the per-job wall-clock cap, which usually
9999+ // means "slow right now") retrying indefinitely on the capped backoff.
100100+ const retryForever = isTransientTransport(err) || err instanceof JobTimeoutError
94101 // eslint-disable-next-line no-await-in-loop
9595- await fail(job.id, job.attempts, err)
102102+ await fail(job.id, job.attempts, err, { retryForever })
96103 }
9710498105 processed++
+18-3
server/utils/queue.ts
···129129 return affected.rowCount ?? affected.affectedRows ?? 0
130130}
131131132132+export interface FailOptions {
133133+ maxAttempts?: number
134134+ /**
135135+ * When true, the job keeps re-queuing past `maxAttempts` instead of being
136136+ * retired to `failed`. For failures that are temporary by nature (a flaky
137137+ * knot SSH endpoint resetting connections), giving up permanently would
138138+ * abandon legitimate work: a push to a real branch must land eventually, not
139139+ * be dropped after 8 unlucky attempts. Backoff still caps at 1h, so an
140140+ * indefinitely-retrying job polls at most hourly rather than hammering.
141141+ */
142142+ retryForever?: boolean
143143+}
144144+132145/**
133146 * Record a failure. Re-queues with exponential backoff until `maxAttempts`,
134134- * after which the job is marked `failed` and stays put for inspection.
147147+ * after which the job is marked `failed` and stays put for inspection, unless
148148+ * `retryForever` is set (see `FailOptions`).
135149 */
136136-export async function fail(id: number, attempts: number, err: unknown, maxAttempts = MAX_ATTEMPTS) {
150150+export async function fail(id: number, attempts: number, err: unknown, opts: FailOptions = {}) {
151151+ const { maxAttempts = MAX_ATTEMPTS, retryForever = false } = opts
137152 const db = useDb()
138153 const message = err instanceof Error ? err.message : String(err)
139154140140- if (attempts >= maxAttempts) {
155155+ if (!retryForever && attempts >= maxAttempts) {
141156 await db.update(job)
142157 .set({ status: 'failed', lastError: message, lockedBy: null, lockedUntil: null, updatedAt: new Date() })
143158 .where(sql`${job.id} = ${id}`)
+13-1
test/unit/queue.spec.ts
···115115 it('marks failed once attempts >= maxAttempts', async () => {
116116 await enqueue('github.push', {})
117117 const claimed = await claim('worker-1', 60_000)
118118- await fail(claimed!.id, 5, new Error('terminal'), 5)
118118+ await fail(claimed!.id, 5, new Error('terminal'), { maxAttempts: 5 })
119119120120 const db = useDb()
121121 const rows = await db.select().from(job).where(sql`${job.id} = ${claimed!.id}`)
122122 expect(rows[0]?.status).toBe('failed')
123123 expect(rows[0]?.lastError).toBe('terminal')
124124+ })
125125+126126+ it('keeps re-queuing past maxAttempts when retryForever is set', async () => {
127127+ await enqueue('github.push', {})
128128+ const claimed = await claim('worker-1', 60_000)
129129+ // Well past the ceiling, but a transient transport failure must not retire.
130130+ await fail(claimed!.id, 99, new Error('ssh error: read ECONNRESET'), { retryForever: true })
131131+132132+ const db = useDb()
133133+ const rows = await db.select().from(job).where(sql`${job.id} = ${claimed!.id}`)
134134+ expect(rows[0]?.status).toBe('queued')
135135+ expect(new Date(rows[0]?.runAfter ?? 0).getTime()).toBeGreaterThan(Date.now())
124136 })
125137})
126138