fix: harden queue lifecycle and publish gate

- Preserve phase results on partial retry and keep interrupted phase
  context after restart.
- Avoid webhook bookkeeping crashes when retention deletes stale jobs.
- Add deeper unit, integration, and e2e coverage around queue seams.
- Require verify job to pass before publish runs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-05-16 18:39:19 +02:00
parent 679053b27d
commit a9429e2118
16 changed files with 1867 additions and 87 deletions

View File

@@ -1,5 +1,29 @@
import { RetryStrategy } from '../src/index.js';
function createJob(retryCount = 0, maxAttempts = 4) {
return {
id: 'job-1',
status: 'active' as const,
data: {},
currentPhase: 'run',
phases: [],
phaseResults: {},
progress: 0,
progressMessage: null,
error: null,
retryCount,
maxAttempts,
webhookUrl: null,
webhookSent: false,
createdAt: new Date().toISOString(),
startedAt: null,
completedAt: null,
updatedAt: new Date().toISOString(),
scheduledAt: null,
cancelledAt: null,
};
}
describe('RetryStrategy', () => {
it('uses exponential backoff for recoverable errors', async () => {
const strategy = new RetryStrategy({
@@ -8,60 +32,64 @@ describe('RetryStrategy', () => {
classifyError: async () => 'recoverable',
});
const decision = await strategy.shouldRetry(new Error('boom'), {
id: 'job-1',
status: 'active',
data: {},
currentPhase: 'run',
phases: [],
phaseResults: {},
progress: 0,
progressMessage: null,
error: null,
retryCount: 1,
maxAttempts: 4,
webhookUrl: null,
webhookSent: false,
createdAt: new Date().toISOString(),
startedAt: null,
completedAt: null,
updatedAt: new Date().toISOString(),
scheduledAt: null,
cancelledAt: null,
});
const decision = await strategy.shouldRetry(new Error('boom'), createJob(1));
expect(decision.retry).toBe(true);
expect(decision.delayMs).toBe(200);
expect(decision.disposition).toBe('recoverable');
});
it('uses fixed backoff when configured', async () => {
const strategy = new RetryStrategy({
maxAttempts: 4,
strategy: 'fixed',
baseDelayMs: 75,
classifyError: async () => 'recoverable',
});
const decision = await strategy.shouldRetry(new Error('boom'), createJob(2));
expect(decision.retry).toBe(true);
expect(decision.delayMs).toBe(75);
});
it('uses linear backoff and caps to maxDelayMs', async () => {
const strategy = new RetryStrategy({
maxAttempts: 6,
strategy: 'linear',
baseDelayMs: 100,
maxDelayMs: 250,
classifyError: async () => 'recoverable',
});
const decision = await strategy.shouldRetry(new Error('boom'), createJob(4, 6));
expect(decision.retry).toBe(true);
expect(decision.delayMs).toBe(250);
});
it('stops retrying when max attempts are exhausted', async () => {
const strategy = new RetryStrategy({
maxAttempts: 3,
classifyError: async () => 'recoverable',
});
const decision = await strategy.shouldRetry(new Error('boom'), createJob(2, 3));
expect(decision).toEqual({
retry: false,
delayMs: 0,
disposition: 'recoverable',
});
});
it('does not retry fatal errors', async () => {
const strategy = new RetryStrategy({
maxAttempts: 4,
classifyError: async () => 'fatal',
});
const decision = await strategy.shouldRetry(new Error('fatal'), {
id: 'job-1',
status: 'active',
data: {},
currentPhase: 'run',
phases: [],
phaseResults: {},
progress: 0,
progressMessage: null,
error: null,
retryCount: 0,
maxAttempts: 4,
webhookUrl: null,
webhookSent: false,
createdAt: new Date().toISOString(),
startedAt: null,
completedAt: null,
updatedAt: new Date().toISOString(),
scheduledAt: null,
cancelledAt: null,
});
const decision = await strategy.shouldRetry(new Error('fatal'), createJob());
expect(decision).toEqual({
retry: false,