# Download System v2 — Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Replace the 9500-line monolithic download-manager.ts with 7 clean modules that fix hanging downloads, wrong error classification, unreliable resume, and extraction loops. **Architecture:** Modular pipeline with typed errors, declarative retry policies, validated resume, and state-machine extraction. Same IPC interface — drop-in replacement. **Tech Stack:** TypeScript, Node.js, Electron IPC, EventEmitter --- ### Task 1: Create error-classifier.ts — Typed Error System **Files:** - Create: `src/main/download/error-classifier.ts` **Step 1: Create the DownloadErrorKind enum and DownloadError class** ```typescript // src/main/download/error-classifier.ts export enum DownloadErrorKind { // Network NetworkReset = "network_reset", Timeout = "timeout", DnsFailure = "dns_failure", // HTTP RangeNotSatisfied = "range_not_satisfied", RangeIgnored = "range_ignored", ServerError = "server_error", RateLimited = "rate_limited", Forbidden = "forbidden", NotFound = "not_found", // Provider/Debrid UnrestrictFailed = "unrestrict_failed", ProviderBusy = "provider_busy", ProviderDown = "provider_down", HosterUnavailable = "hoster_unavailable", LinkDead = "link_dead", QuotaExceeded = "quota_exceeded", // Filesystem DiskFull = "disk_full", PermissionDenied = "permission_denied", FileLocked = "file_locked", // Integrity FileCorrupt = "file_corrupt", FileTruncated = "file_truncated", ResumeUnderflow = "resume_underflow", // Extraction WrongPassword = "wrong_password", ArchiveCorrupt = "archive_corrupt", ExtractorCrash = "extractor_crash", // Catchall Unknown = "unknown", } export class DownloadError extends Error { readonly kind: DownloadErrorKind; readonly retryable: boolean; readonly permanent: boolean; readonly httpStatus?: number; readonly originalError?: Error; constructor(kind: DownloadErrorKind, message: string, opts?: { httpStatus?: number; originalError?: Error; retryable?: boolean; permanent?: boolean; }) { super(message); this.name = "DownloadError"; this.kind = kind; this.retryable = opts?.retryable ?? !isPermanentKind(kind); this.permanent = opts?.permanent ?? isPermanentKind(kind); this.httpStatus = opts?.httpStatus; this.originalError = opts?.originalError; } } ``` **Step 2: Create classifyFetchError — network-level errors** Classify raw fetch/network errors at the point they occur. Covers ECONNRESET, socket hang up, DNS, timeout, ENOSPC, EACCES, EPERM, EBUSY. **Step 3: Create classifyHttpStatus — HTTP response errors** Classify HTTP status codes: 416 → RangeNotSatisfied, 429 → RateLimited, 403 → Forbidden, 404 → NotFound, 5xx → ServerError. Also detect range-ignored (200 when Range was sent). **Step 4: Create classifyUnrestrictError — debrid API errors** Classify unrestrict response errors by checking for known patterns: - "file not found", "file deleted", "link is dead" → LinkDead (permanent) - "too many active", "concurrent limit" → ProviderBusy - "hosternotavailable" → HosterUnavailable - "server error", "maintenance", "cloudflare" → ProviderDown - "quota", "traffic" → QuotaExceeded - Everything else → UnrestrictFailed **Step 5: Create classifyExtractionError** Classify extraction failures: wrong_password → WrongPassword, corrupt header → ArchiveCorrupt, process crash → ExtractorCrash. **Step 6: Helper function isPermanentKind** Returns true for LinkDead, DiskFull, PermissionDenied — errors where retrying is pointless. --- ### Task 2: Create retry-manager.ts — Declarative Retry Logic **Files:** - Create: `src/main/download/retry-manager.ts` **Step 1: Define RetryPolicy interface and RETRY_POLICIES map** ```typescript export interface RetryPolicy { maxRetries: number; // 0 = permanent failure, no retry backoff: "fixed" | "exponential"; baseDelayMs: number; maxDelayMs: number; resetFile: boolean; // Delete partial file before retry switchProvider: boolean; // Try different debrid provider refreshLink: boolean; // Get new direct link providerCooldownMs: number; // Apply cooldown to current provider (0 = no cooldown) } export const RETRY_POLICIES: Record = { [DownloadErrorKind.NetworkReset]: { maxRetries: 3, backoff: "fixed", baseDelayMs: 300, maxDelayMs: 300, resetFile: true, switchProvider: false, refreshLink: false, providerCooldownMs: 0 }, [DownloadErrorKind.Timeout]: { maxRetries: 10, backoff: "exponential", baseDelayMs: 200, maxDelayMs: 30000, resetFile: false, switchProvider: false, refreshLink: false, providerCooldownMs: 0 }, [DownloadErrorKind.DnsFailure]: { maxRetries: 2, backoff: "fixed", baseDelayMs: 5000, maxDelayMs: 5000, resetFile: false, switchProvider: false, refreshLink: false, providerCooldownMs: 0 }, [DownloadErrorKind.RangeNotSatisfied]: { maxRetries: 2, backoff: "fixed", baseDelayMs: 200, maxDelayMs: 200, resetFile: true, switchProvider: false, refreshLink: true, providerCooldownMs: 0 }, [DownloadErrorKind.RangeIgnored]: { maxRetries: 3, backoff: "fixed", baseDelayMs: 300, maxDelayMs: 300, resetFile: false, switchProvider: false, refreshLink: true, providerCooldownMs: 0 }, [DownloadErrorKind.ServerError]: { maxRetries: 5, backoff: "exponential", baseDelayMs: 2000, maxDelayMs: 60000, resetFile: false, switchProvider: false, refreshLink: true, providerCooldownMs: 0 }, [DownloadErrorKind.RateLimited]: { maxRetries: 8, backoff: "exponential", baseDelayMs: 5000, maxDelayMs: 120000, resetFile: false, switchProvider: false, refreshLink: false, providerCooldownMs: 0 }, [DownloadErrorKind.Forbidden]: { maxRetries: 2, backoff: "fixed", baseDelayMs: 1000, maxDelayMs: 1000, resetFile: false, switchProvider: false, refreshLink: true, providerCooldownMs: 0 }, [DownloadErrorKind.NotFound]: { maxRetries: 1, backoff: "fixed", baseDelayMs: 2000, maxDelayMs: 2000, resetFile: false, switchProvider: false, refreshLink: true, providerCooldownMs: 0 }, [DownloadErrorKind.UnrestrictFailed]: { maxRetries: 5, backoff: "exponential", baseDelayMs: 5000, maxDelayMs: 120000, resetFile: false, switchProvider: true, refreshLink: false, providerCooldownMs: 20000 }, [DownloadErrorKind.ProviderBusy]: { maxRetries: 8, backoff: "exponential", baseDelayMs: 5000, maxDelayMs: 60000, resetFile: false, switchProvider: true, refreshLink: false, providerCooldownMs: 12000 }, [DownloadErrorKind.ProviderDown]: { maxRetries: 5, backoff: "exponential", baseDelayMs: 10000, maxDelayMs: 180000, resetFile: false, switchProvider: true, refreshLink: false, providerCooldownMs: 30000 }, [DownloadErrorKind.HosterUnavailable]: { maxRetries: 5, backoff: "exponential", baseDelayMs: 5000, maxDelayMs: 30000, resetFile: false, switchProvider: true, refreshLink: false, providerCooldownMs: 15000 }, [DownloadErrorKind.LinkDead]: { maxRetries: 0, backoff: "fixed", baseDelayMs: 0, maxDelayMs: 0, resetFile: false, switchProvider: false, refreshLink: false, providerCooldownMs: 0 }, [DownloadErrorKind.QuotaExceeded]: { maxRetries: 3, backoff: "exponential", baseDelayMs: 30000, maxDelayMs: 300000, resetFile: false, switchProvider: true, refreshLink: false, providerCooldownMs: 60000 }, [DownloadErrorKind.DiskFull]: { maxRetries: 0, backoff: "fixed", baseDelayMs: 0, maxDelayMs: 0, resetFile: false, switchProvider: false, refreshLink: false, providerCooldownMs: 0 }, [DownloadErrorKind.PermissionDenied]: { maxRetries: 0, backoff: "fixed", baseDelayMs: 0, maxDelayMs: 0, resetFile: false, switchProvider: false, refreshLink: false, providerCooldownMs: 0 }, [DownloadErrorKind.FileLocked]: { maxRetries: 3, backoff: "exponential", baseDelayMs: 1000, maxDelayMs: 10000, resetFile: false, switchProvider: false, refreshLink: false, providerCooldownMs: 0 }, [DownloadErrorKind.FileCorrupt]: { maxRetries: 2, backoff: "fixed", baseDelayMs: 500, maxDelayMs: 500, resetFile: true, switchProvider: false, refreshLink: true, providerCooldownMs: 0 }, [DownloadErrorKind.FileTruncated]: { maxRetries: 3, backoff: "fixed", baseDelayMs: 300, maxDelayMs: 300, resetFile: true, switchProvider: false, refreshLink: true, providerCooldownMs: 0 }, [DownloadErrorKind.ResumeUnderflow]: { maxRetries: 2, backoff: "fixed", baseDelayMs: 300, maxDelayMs: 300, resetFile: true, switchProvider: false, refreshLink: true, providerCooldownMs: 0 }, [DownloadErrorKind.WrongPassword]: { maxRetries: 0, backoff: "fixed", baseDelayMs: 0, maxDelayMs: 0, resetFile: false, switchProvider: false, refreshLink: false, providerCooldownMs: 0 }, [DownloadErrorKind.ArchiveCorrupt]: { maxRetries: 1, backoff: "fixed", baseDelayMs: 1000, maxDelayMs: 1000, resetFile: true, switchProvider: false, refreshLink: true, providerCooldownMs: 0 }, [DownloadErrorKind.ExtractorCrash]: { maxRetries: 1, backoff: "fixed", baseDelayMs: 2000, maxDelayMs: 2000, resetFile: false, switchProvider: false, refreshLink: false, providerCooldownMs: 0 }, [DownloadErrorKind.Unknown]: { maxRetries: 5, backoff: "exponential", baseDelayMs: 1000, maxDelayMs: 60000, resetFile: false, switchProvider: false, refreshLink: false, providerCooldownMs: 0 }, }; ``` **Step 2: Create RetryManager class** ```typescript export interface RetryState { failuresByKind: Partial>; totalFailures: number; shelveCount: number; lastError?: DownloadError; } export interface RetryDecision { shouldRetry: boolean; delayMs: number; actions: RetryAction[]; reason: string; // Human-readable status message } export type RetryAction = "reset_file" | "switch_provider" | "refresh_link" | "cooldown_provider" | "shelve"; export class RetryManager { private states: Map = new Map(); private userRetryLimit: number = 0; // 0 = unlimited constructor(retryLimit: number); setRetryLimit(limit: number): void; /** * Record a failure and decide whether to retry. */ evaluate(itemId: string, error: DownloadError): RetryDecision; /** * Reset retry state for an item (manual reset). */ resetItem(itemId: string): void; /** * Get current retry state (for persistence). */ getState(itemId: string): RetryState | undefined; /** * Restore retry state (from persisted session). */ restoreState(itemId: string, state: RetryState): void; /** * Export all states for persistence. */ exportStates(): Record; /** * Import states from persistence. */ importStates(states: Record): void; /** * Remove state for deleted items. */ removeItem(itemId: string): void; } ``` Key logic: - `evaluate()` checks policy for error.kind, compares against current failure count - If totalFailures >= 15 (SHELVE_THRESHOLD): shelve (90s pause + half-reset counters + switch provider) - User retryLimit overrides policy maxRetries if set (retryLimit > 0) - Backoff calculation: exponential = baseDelayMs * 1.5^(attempt-1) with jitter, capped at maxDelayMs - Returns structured RetryDecision with all actions the caller needs to execute --- ### Task 3: Create stream-writer.ts — HTTP Streaming with Validated Resume **Files:** - Create: `src/main/download/stream-writer.ts` **Step 1: Define StreamResult and StreamOptions interfaces** ```typescript export interface StreamOptions { url: string; targetPath: string; expectedBytes: number | null; downloadedBytes: number; // Previously downloaded (for resume validation) stallTimeoutMs: number; connectTimeoutMs: number; skipTlsVerify: boolean; speedLimitBps: number; // 0 = no limit signal: AbortSignal; onProgress: (bytes: number, totalBytes: number | null, speedBps: number) => void; onHeartbeat: () => void; // Called every ~1s even during slow transfer onResumable: (resumable: boolean) => void; onFileNameOverride: (newName: string) => void; // Content-Disposition } export interface StreamResult { totalBytes: number; downloadedBytes: number; resumable: boolean; fileName?: string; // If Content-Disposition provided new name completed: boolean; } ``` **Step 2: Implement streamToFile function** Core logic: 1. **Pre-resume validation**: stat existing file, compare with downloadedBytes - If file doesn't exist → fresh download (downloadedBytes = 0) - If file.size matches downloadedBytes (±4KB) → resume from file.size - If file.size > downloadedBytes + 1MB → truncate to downloadedBytes (sparse file fix) - If file.size < downloadedBytes (but > 0) → file was corrupted, delete and restart 2. **HTTP request**: send Range header if resuming, detect 206/200/416 3. **416 handling**: check Content-Range for total, if file complete → accept, else throw RangeNotSatisfied 4. **200 with Range sent**: throw RangeIgnored 5. **Streaming loop**: buffered read with stall timeout, write with NTFS alignment, backpressure handling 6. **Heartbeat**: emit heartbeat every 1s regardless of transfer state 7. **Speed limiting**: token bucket or simple delay between chunks 8. **Content-Disposition**: parse filename, notify via callback 9. **Sparse pre-allocation**: on Windows, pre-allocate file with truncate for fresh downloads **Step 3: Implement stall detection within the stream loop** - Read with timeout (stallTimeoutMs) - If timeout → throw DownloadError(Timeout) - Track blockedOnDiskWrite state (write backpressure) - Drain timeout for slow disks (5 min) **Step 4: Implement the buffered writer with NTFS alignment** - 512KB write buffer - Flush aligned to 4KB boundaries - Final flush writes remaining bytes - Backpressure: await stream.drain() when write returns false --- ### Task 4: Create pipeline.ts — Single Download Lifecycle **Files:** - Create: `src/main/download/pipeline.ts` **Step 1: Define PipelineContext and PipelineResult** ```typescript export interface PipelineContext { item: DownloadItem; package: PackageEntry; settings: AppSettings; debridService: DebridService; signal: AbortSignal; cachedDirectUrl?: string; // Reuse from previous attempt onStatus: (status: DownloadStatus, fullStatus: string) => void; onProgress: (bytes: number, total: number | null, speed: number) => void; onResumable: (resumable: boolean) => void; onFileNameOverride: (newName: string) => void; onProviderInfo: (provider: DebridProvider, label?: string, accountId?: string, accountLabel?: string) => void; onHeartbeat: () => void; } export interface PipelineResult { success: boolean; downloadedBytes: number; totalBytes: number | null; directUrl?: string; // For caching across retries resumable: boolean; } ``` **Step 2: Implement runPipeline function** ```typescript export async function runPipeline(ctx: PipelineContext): Promise ``` Steps within the pipeline: 1. **Unrestrict**: Call debridService.unrestrict() with abort signal, apply TLS skip if needed - On error → classifyUnrestrictError() → throw DownloadError - On success → emit provider info, update status to "downloading" 2. **Stream**: Call streamToFile() with resolved direct URL - On error → classifyFetchError() or classifyHttpStatus() → throw DownloadError - On progress → forward to ctx.onProgress 3. **Integrity check** (if enabled): Call validateFileAgainstManifest() - On mismatch → throw DownloadError(FileCorrupt) 4. Return PipelineResult with final state The pipeline does NOT handle retries — it runs once and either succeeds or throws a typed DownloadError. The caller (download-manager + retry-manager) decides what to do with errors. --- ### Task 5: Create post-processor.ts — Extraction State Machine **Files:** - Create: `src/main/download/post-processor.ts` **Step 1: Define extraction state types** ```typescript export interface ArchiveExtractionState { archiveName: string; status: "pending" | "extracting" | "done" | "failed"; attempts: number; maxAttempts: number; // Default 3 redownloaded: boolean; lastError?: string; lastErrorKind?: DownloadErrorKind; } export interface PackagePostProcessState { packageId: string; status: "pending" | "extracting" | "done" | "failed" | "aborted"; archives: Map; startedAt: number; completedAt?: number; } ``` **Step 2: Implement PostProcessor class** ```typescript export class PostProcessor extends EventEmitter { private states: Map = new Map(); private abortControllers: Map = new Map(); private activeCount: number = 0; private maxParallel: number; constructor(maxParallel: number); /** * Queue a package for post-processing (extraction). */ queuePackage(packageId: string, options: PostProcessOptions): void; /** * Run the post-processing queue. */ async processQueue(): Promise; /** * Abort processing for a specific package. */ abortPackage(packageId: string): void; /** * Abort all active post-processing. */ abortAll(): void; /** * Retry extraction for a specific package. */ retryPackage(packageId: string): void; /** * Get state for a package. */ getState(packageId: string): PackagePostProcessState | undefined; /** * Check if any processing is active. */ isActive(): boolean; // Events: // "progress" → { packageId, update: ExtractProgressUpdate } // "package-done" → { packageId, success: boolean, errors: string[] } // "archive-redownload" → { packageId, archiveName } // "status" → { packageId, label: string } } ``` Key rules: - Max 3 extraction attempts per archive - If ArchiveCorrupt + not yet redownloaded → emit "archive-redownload", set redownloaded=true - If ArchiveCorrupt + already redownloaded → fail permanently - If WrongPassword → try all passwords in list, then fail - If ExtractorCrash → retry once, then fail - Package "done" only when ALL archives are done or permanently failed - Package "failed" if ANY archive failed permanently - No infinite loops possible (hard cap on attempts) --- ### Task 6: Create scheduler.ts — Queue Management & Slot Allocation **Files:** - Create: `src/main/download/scheduler.ts` **Step 1: Define scheduler types** ```typescript export interface SchedulerConfig { maxParallel: number; stallTimeoutMs: number; globalStallWatchdogMs: number; allDebridStaggerMs: number; } export interface SlotRequest { itemId: string; packageId: string; } export interface ProviderCooldown { provider: string; cooldownUntil: number; failureCount: number; } ``` **Step 2: Implement Scheduler class** ```typescript export class Scheduler extends EventEmitter { private generation: number = 0; private running: boolean = false; private paused: boolean = false; // Active download tracking private activeSlots: Map = new Map(); // Provider cooldowns (circuit breaker) private providerCooldowns: Map = new Map(); // Retry delays per item private retryDelays: Map = new Map(); // itemId → retryAfterEpochMs constructor(config: SchedulerConfig); /** * Start the scheduler loop. */ async start(findNextItem: () => SlotRequest | null, startItem: (slot: SlotRequest) => void): Promise; /** * Stop the scheduler (bumps generation to kill old loop). */ stop(): void; /** * Pause/unpause slot allocation. */ setPaused(paused: boolean): void; /** * Register an item as actively downloading. */ claimSlot(itemId: string, packageId: string): void; /** * Release a slot (download finished/failed/cancelled). */ releaseSlot(itemId: string): void; /** * Record heartbeat from active download. */ heartbeat(itemId: string, downloadedBytes: number): void; /** * Schedule a retry delay for an item. */ scheduleRetry(itemId: string, delayMs: number): void; /** * Check if an item is delayed (retry pending). */ isDelayed(itemId: string): boolean; /** * Apply provider cooldown. */ applyProviderCooldown(provider: string, cooldownMs: number): void; /** * Check if provider is in cooldown. */ getProviderCooldownRemaining(provider: string): number; /** * Get number of active slots. */ get activeCount(): number; /** * Check if scheduler has capacity for more downloads. */ hasCapacity(): boolean; // Events: // "stall-detected" → { itemId } (per-item stall from heartbeat monitoring) // "global-stall" → { itemIds: string[] } (all downloads stalled) // "run-complete" → {} (no more items to process) } ``` Key logic: - Scheduler loop runs at 120ms intervals, checking for available slots - Global stall watchdog: if zero bytes across ALL downloads for globalStallWatchdogMs → emit "global-stall" - Per-item heartbeat monitoring: if no heartbeat for stallTimeoutMs → emit "stall-detected" - Provider cooldowns: checked in findNextItem filter - Retry delays: checked in findNextItem filter - Generation guard: stop() bumps generation, old loop exits --- ### Task 7: Create download-manager.ts — Orchestrator (Drop-in Replacement) **Files:** - Create: `src/main/download/download-manager.ts` - Create: `src/main/download/index.ts` (re-export) **Step 1: Create the DownloadManager class with same constructor signature** Same constructor as current: `(settings, session, storagePaths, options?)`. Must extend EventEmitter. Must emit "state" events with UiSnapshot. **Step 2: Implement queue management methods** Port directly from old code (these are mostly unchanged): - `addPackages()`, `clearAll()`, `exportQueue()`, `importQueue()` - `renamePackage()`, `reorderPackages()`, `togglePackage()`, `cancelPackage()`, `resetPackage()` - `setPackagePriority()`, `removeItem()`, `skipItems()`, `resetItems()` - `getSnapshot()`, `getStats()`, `getSessionStats()` **Step 3: Implement start/stop/pause using new Scheduler** - `start()`: create Scheduler, RetryManager, and begin processing - `stop()`: stop Scheduler, abort all active pipelines, persist retry state - `togglePause()`: delegate to Scheduler.setPaused() **Step 4: Wire up Pipeline execution** When Scheduler requests a new download: 1. Create AbortController for the item 2. Call `runPipeline()` with item context 3. On success → mark completed, release slot, trigger post-processing if package done 4. On DownloadError → call `RetryManager.evaluate()` - If shouldRetry: execute actions (reset file, switch provider, etc.), schedule retry delay - If !shouldRetry: mark failed **Step 5: Wire up PostProcessor** - Listen for "package-done" → update package status, trigger cleanup, add history entry - Listen for "archive-redownload" → re-queue download item - Listen for "progress" → forward extraction progress to UI **Step 6: Wire up Scheduler events** - "stall-detected" → abort the stalled download, retry via RetryManager - "global-stall" → abort all, re-queue all active items - "run-complete" → finalize session, create summary **Step 7: Implement persistence** - Same debounced persistSoon() / persistNow() pattern - RetryManager states persisted alongside session - PostProcessor states persisted alongside session **Step 8: Implement speed/ETA calculation** Port from old code: moving window speed, per-package speed, ETA calculation. **Step 9: Implement reconnect handling** Port 429/503 reconnect logic using new error types (RateLimited → reconnect wait). **Step 10: Create index.ts re-export** ```typescript // src/main/download/index.ts export { DownloadManager } from "./download-manager"; ``` --- ### Task 8: Integration — Switch Import & Test **Files:** - Modify: `src/main/app-controller.ts` — change import path - Keep: `src/main/download-manager.ts` — old file stays as reference **Step 1: Update import in app-controller.ts** Change from: ```typescript import { DownloadManager } from "./download-manager"; ``` To: ```typescript import { DownloadManager } from "./download/download-manager"; ``` **Step 2: Build and verify** ```bash npm run build ``` Fix any TypeScript compilation errors. **Step 3: Run existing tests** ```bash npx vitest run --reporter=verbose tests/utils.test.ts tests/storage.test.ts tests/integrity.test.ts tests/cleanup.test.ts tests/extractor.test.ts tests/debrid.test.ts tests/update.test.ts tests/auto-rename.test.ts ``` All should still pass since we didn't change the external modules. --- ### Task 9: Write Unit Tests for New Modules **Files:** - Create: `tests/error-classifier.test.ts` - Create: `tests/retry-manager.test.ts` **Step 1: Test error classification** Test that every known error string maps to the correct DownloadErrorKind: - "socket hang up" → NetworkReset - "ECONNRESET" → NetworkReset - "file not found" → LinkDead - "too many active" → ProviderBusy - HTTP 416 → RangeNotSatisfied - HTTP 429 → RateLimited - etc. **Step 2: Test retry decisions** - NetworkReset: retries 3 times with 300ms delay, then fails - LinkDead: fails immediately (maxRetries = 0) - ProviderBusy: retries with exponential backoff, switches provider - After 15 total failures: shelve (90s delay, halved counters) - User retryLimit override works **Step 3: Test shelving logic** - Accumulate 15 failures across different kinds - Verify counters are halved - Verify 90s delay applied - Verify provider switch requested --- ### Task 10: Cleanup & Finalize **Step 1: Verify full build** ```bash npm run build ``` **Step 2: Run all fast tests** ```bash npx vitest run --reporter=verbose ``` **Step 3: Remove old download-manager.ts** (only after confirming stability) **Step 4: Commit** ```bash git add src/main/download/ tests/error-classifier.test.ts tests/retry-manager.test.ts git commit -m "feat: replace monolithic download-manager with modular download system v2 - Split 9500-line download-manager.ts into 7 focused modules - Add typed error classification (DownloadErrorKind enum) - Add declarative retry policies per error type - Add validated resume (pre-check file integrity before Range header) - Add extraction state machine (max 3 retries, no infinite loops) - Same IPC interface — drop-in replacement" ```