User: taskstream needs exponential backoff retry for failed webhook deliveries. Right now if a delivery fails it just drops the event. We need up to 5 retry attempts with exponential backoff, delays stored in Redis so retries survive restarts.

Agent: I'll design this before writing code. The approach: use a Redis sorted set where the score is the Unix timestamp of the next attempt. A background worker polls for jobs whose score is <= now, attempts delivery, and either marks the job done or reschedules it with a doubled delay. After 5 attempts the job is moved to a dead-letter key.

Does this fit the existing architecture? Let me check what's already in place.

[Read: src/delivery/deliver.ts]
[Read: src/worker/retry-worker.ts]

The deliver.ts module handles HTTP POST; retry-worker.ts exists but is a stub. Good starting point.

User: That architecture sounds right. Go ahead.

Agent: [Edit: src/delivery/retry-queue.ts] (new file)

```typescript
import { createClient } from "redis";

const RETRY_KEY = "taskstream:retry";
const DEAD_KEY  = "taskstream:dead";
const MAX_ATTEMPTS = 5;
const BASE_DELAY_MS = 5_000;

export async function enqueueRetry(
  redis: ReturnType<typeof createClient>,
  jobId: string,
  attempt: number,
): Promise<void> {
  if (attempt >= MAX_ATTEMPTS) {
    await redis.lPush(DEAD_KEY, jobId);
    return;
  }
  const delayMs = BASE_DELAY_MS * Math.pow(2, attempt);
  const score = Date.now() + delayMs;
  await redis.zAdd(RETRY_KEY, { score, value: jobId });
}

export async function pollDueRetries(
  redis: ReturnType<typeof createClient>,
): Promise<string[]> {
  const now = Date.now();
  return redis.zRangeByScore(RETRY_KEY, "-inf", now);
}

export async function removeFromQueue(
  redis: ReturnType<typeof createClient>,
  jobId: string,
): Promise<void> {
  await redis.zRem(RETRY_KEY, jobId);
}
```

[Edit: src/worker/retry-worker.ts]

Updated the stub to poll every 5 seconds, attempt delivery for each due job, and call enqueueRetry with incremented attempt count on failure or removeFromQueue on success.

User: What's the delay schedule for 5 attempts?

Agent: With BASE_DELAY_MS = 5000 and doubling per attempt:
- Attempt 0 -> 1: 5 seconds
- Attempt 1 -> 2: 10 seconds
- Attempt 2 -> 3: 20 seconds
- Attempt 3 -> 4: 40 seconds
- Attempt 4 -> 5: dead letter (no 6th attempt)

Total window before dead-letter: ~75 seconds. If you want longer, we can increase BASE_DELAY_MS or switch from 2x to a larger multiplier.

User: 75 seconds is fine for v1. What key format is used for job IDs?

Agent: [Read: src/jobs/job-id.ts]

Job IDs are UUIDs generated at event creation time, e.g. `evt_a3f7c290`. The retry queue stores these as ZADD values directly.

User: Add a unit test for the delay schedule.

Agent: [Write: tests/unit/delivery/retry-queue.test.ts]

Test covers: first enqueue sets correct score within 100ms tolerance, fifth attempt goes to dead letter, pollDueRetries returns jobs whose score is in the past, removeFromQueue removes only the target job.

User: Run it.

Agent: [Bash: npm test -- tests/unit/delivery/retry-queue.test.ts]

```
PASS tests/unit/delivery/retry-queue.test.ts
  enqueueRetry
    ✓ schedules retry with exponential delay (12ms)
    ✓ moves to dead letter after MAX_ATTEMPTS (8ms)
  pollDueRetries
    ✓ returns only due jobs (5ms)
  removeFromQueue
    ✓ removes target job only (4ms)
```

All passing.
