<reasoning_content>
*   Goal: Refactor a job queue into three specific composable classes in `solution.py`.
    *   Classes to define:
        1.  `Job` (dataclass): `id`, `data`, `priority`, `retries`.
        2.  `RetryPolicy`: `max_attempts`, `run(fn, data) -> (success, attempts_made)`. No actual sleeping.
        3.  `JobQueue`: `add_job`, `process_job` (uses `RetryPolicy`), `get_next_job` (highest priority, then FIFO).
    *   Constraints: Standard library only, no external packages, no explanations, just code.

    *   Fields: `id: str`, `data: dict`, `priority: int = 0`, `retries: int = 0`.
    *   Need `@dataclass`.

    *   `__init__(self, max_attempts: int = 4)`.
    *   `run(self, fn: Callable, data) -> tuple[bool, int]`.
    *   Logic: Call `fn(data)`. If exception, increment attempt. Loop until success or `max_attempts`.
    *   Return: `(True, attempts)` or `(False, attempts)`.

    *   Needs a storage mechanism for jobs.
    *   `add_job(self, job_id: str, data: dict, priority: int = 0)`.
    *   `get_next_job(self) -> tuple[str, dict] | None`. Needs priority queue behavior.
        *   Python's `heapq` is a min-heap.
        *   Requirement: "highest-priority job's (id, data); FIFO order within the same priority".
        *   Usually, "priority 10" > "priority 0". But in many queue systems, "priority 1" is higher than "priority 2".
        *   Let's check standard conventions. Often, higher number = higher priority in custom implementations, or lower number = higher priority (like Unix nice values).
        *   Requirement says: "highest-priority".
        *   If priority is an integer, typically we use a heap.
        *   To implement FIFO within same priority: Use a counter/index as a tie-breaker.
        *   To handle "highest priority" (if higher number = higher priority): Use `-priority` in a min-heap.
    *   `process_job(self, job_id: str, processor: Callable) -> bool`.
        *   Must use `RetryPolicy`.
        *   Wait, `process_job` takes a `job_id`. Does it pull from the queue or is the job already there? The instruction says "orchestrates Jobs using RetryPolicy".
        *   The signature `process_job(self, job_id: str, processor: Callable) -> bool` implies we look up a job by ID, try to process it, and return success/failure.
        *   However, `JobQueue` needs to store jobs.
        *   If `process_job` takes a `job_id`, I need a dictionary to look up jobs.
        *   But `get_next_job` suggests a queue-like structure.
        *   Let's assume `add_job` adds to both a priority queue (for `get_next_job`) and a lookup dict (for `process_job`).
        *   Wait, if `process_job` processes a specific job ID, where does the data come from? It must come from the stored `Job`.

    *   `JobQueue`:
        *   `self.jobs: dict[str, Job]` (for lookup).
        *   `self.queue: list` (for `heapq`).
        *   `self.counter: int` (for FIFO).
        *   `self.retry_policy: RetryPolicy`.

    *   `add_job`:
        ```python
        job = Job(id=job_id, data=data, priority=priority)
        self.jobs[job_id] = job
        heapq.heappush(self.queue, (-priority, self.counter, job_id))
        self.counter += 1
        ```
    *   `get_next_job`:
        ```python
        if not self.queue: return None
        _, _, job_id = heapq.heappop(self.queue)
        job = self.jobs.pop(job_id)
        return job.id, job.data
        ```
        *Wait*, if `get_next_job` removes it, `process_job` might not find it if it's called after `get_next_job`. But `process_job` signature suggests it's an operation on a known ID.
        Actually, `process_job` is a bit ambiguous. Is it processing a job that was just popped or a job that is currently in the queue?
        If `process_job` is for a specific ID, it should look up the job in the storage.
        If the job is removed by `get_next_job`, `process_job` won't find it.
        Let's refine:
        `add_job` stores it.
        `get_next_job` returns it but *maybe* doesn't remove it? No, "get next" usually implies popping in a queue.
        Wait, if `process_job` takes a `job_id`, it's likely meant to be used on a job already retrieved or existing.
        Let's look at `process_job` again: `process_job(self, job_id: str, processor: Callable) -> bool`.
        If I pop the job in `get_next_job`, it's gone from the queue.
        Perhaps `process_job` is the primary way to handle things? But `get_next_job` exists.
        Let's assume `get_next_job` returns the info, and `process_job` is a method to run a processor on a job identified by ID.
        Let's make `process_job` look into the `self.jobs` dict.

    *   Implementation detail: `RetryPolicy.run`
        ```python
        def run(self, fn, data):
            attempts = 0
            while attempts < self.max_attempts:
                attempts += 1
                try:
                    fn(data)
                    return True, attempts
                except Exception:
                    pass
            return False, attempts
        ```

    *   `JobQueue.process_job`:
        ```python
        def process_job(self, job_id: str, processor: Callable) -> bool:
            job = self.jobs.get(job_id)
            if not job: return False
            success, attempts = self.retry_policy.run(processor, job.data)
            if success:
                del self.jobs[job_id]
                # How to remove from heap? Heap removal is hard.
                # Maybe the heap should only contain IDs that are still in self.jobs.
                # When popping from heap, if ID not in self.jobs, ignore and pop next.
                return True
            else:
                job.retries = attempts # Or something? The instruction says Job has retries field.
                return False
        ```

    *   Wait, let's re-read `get_next_job`: "return the highest-priority job's (id, data)".
        If `get_next_job` is used, it is removed from the queue.
        If `process_job` is used, it processes a job.
        This is slightly contradictory if they are used sequentially on the same job.
        However, if `get_next_job` is meant for a worker to find work, and `process_job` is the logic to execute it...
        But `process_job` takes `job_id`.
        Let's assume:
        1. `add_job` puts it in the queue.
        2. `get_next_job` pops it from the queue and returns info.
        3. `process_job` is used to execute the work *given* an ID.
        If `process_job` is the one that handles retries and success/failure, maybe `get_next_job` shouldn't be used to *remove* it, but just to *peek*? No, "get next" usually means pop.
        Let's look at `process_job`'s requirement: "orchestrates Jobs using RetryPolicy".
        Maybe the user wants:
        `add_job` -> queue.
        `get_next_job` -> pop from queue.
        `process_job` -> The user calls `get_next_job` to get an ID, then calls `process_job` with that ID.
        But if `get_next_job` popped it, it's not in the queue/dict anymore.
        
        *Alternative interpretation*:
        `get_next_job` returns the next available job.
        `process_job` takes the job ID and runs it.
        If `get_next_job` is the only way to "consume" the queue, then `process_job` must be the way to actually *do* the work.
        
        Let's try this structure:
        - `self.jobs`: dict `{job_id: Job}`.
        - `self.priority_queue`: heap of `(-priority, counter, job_id)`.
        - `add_job`: add to both.
        - `get_next_job`: pop from heap, if ID not in dict (already processed), pop next. Return `(id, data)`. (Wait, if I pop it here, `process_job` can't find it in the dict).
        
        *Actually*, many job queue designs work like this:
        1. `add_job` adds to queue.
        2. `get_next_job` returns the next job but *doesn't* remove it (peek), OR it returns it and the caller is responsible.
        
        Wait, the instruction for `get_next_job` is: `get_next_job(self) -> tuple[str, dict] | None`. It doesn't say it removes it. But usually "get next" in a queue context implies consumption.
        However, `process_job` takes a `job_id`. This implies the job *must* be accessible via `job_id`.
        
        Let's design it so `process_job` is the consumer.
        If I call `get_next_job`, I get the ID. Then I call `process_job(id, processor)`.
        To make `process_job` work, the job must still be in `self.jobs`.
        So `get_next_job` should NOT remove it from `self.jobs`, but it *should* remove it from the `priority_queue`? No, because if it's removed from the queue, it's "being processed".
        
        Actually, looking at the prompt again:
        `get_next_job` returns `(id, data)`.
        `process_job` returns `bool`.
        
        Let's go with this:
        `add_job` -> adds to `self._jobs` (dict) and `self._queue` (heap).
        `get_next_job` -> `heapq.heappop` from `self._queue`. If the popped `job_id` is not in `self._jobs`, pop again. Return `(id, data)` from `self._jobs`. (Crucially, do NOT `del` from `self._jobs` yet).
        `process_job` -> uses `RetryPolicy.run(processor, job.data)`. If success, `del self._jobs[job_id]`. If fail, update `job.retries`.

        Wait, if `get_next_job` doesn't remove it from `self._jobs`, then `process_job` can find it.
        If `process_job` fails, the job is still in `self._jobs`.
        But if `get_next_job` popped it from the heap, it won't be picked up by `get_next_job` again. This makes sense—it's "in progress".
        
        Wait, if `process_job` fails, the job is effectively lost if it's not put back in the heap.
        Let's refine the flow:
        - `add_job`: `self.jobs[id] = Job(...)`, `heappush(self.queue, (-priority, counter, id))`.
        - `get_next_job`: 
          While queue:
             `p, c, id = heappop(queue)`
             if `id` in `self.jobs`: return `id, self.jobs[id].data`
          return `None`
        - `process_job`:
          `job = self.jobs.get(job_id)`
          if not job: return `False`
          `success, attempts = self.retry_policy.run(processor, job.data)`
          if `success`:
             `del self.jobs[job_id]`
             return `True`
          else:
             `job.retries = attempts` (or however we want to track it)
             # Should we put it back in the queue? The prompt doesn't say. 
             # It says "retries on any exception until success or max_attempts reached".
             # This happens *inside* `RetryPolicy.run`.
             # So `process_job` only returns `False` if the `RetryPolicy` exhausted all attempts.
             # If it's exhausted, it's a failed job.
             return `False`

    *   `Job`:
        ```python
        @dataclass
        class Job:
            id: str
            data: dict
            priority: int = 0
            retries: int = 0
        ```
    *   `RetryPolicy`:
        ```python
        class RetryPolicy:
            def __init__(self, max_attempts: int = 4):
                self.max_attempts = max_attempts
            def run(self, fn, data):
                attempts = 0
                for i in range(self.max_attempts):
                    attempts += 1
                    try:
                        fn(data)
                        return True, attempts
                    except Exception:
                        pass
                return False, attempts
        ```
    *   `JobQueue`:
        ```python
        import heapq
        from dataclasses import dataclass
        from typing import Callable, Any

        class JobQueue:
            def __init__(self):
                self._jobs = {}
                self._queue = []
                self._counter = 0
                self._retry_policy = RetryPolicy()

            def add_job(self, job_id, data, priority=0):
                job = Job(id=job_id, data=data, priority=priority)
                self._jobs[job_id] = job
                heapq.heappush(self._queue, (-priority, self._counter, job_id))
                self._counter += 1

            def get_next_job(self):
                while self._queue:
                    _, _, job_id = heapq.heappop(self._queue)
                    if job_id in self._jobs:
                        job = self._jobs[job_id]
                        return job.id, job.data
                return None

            def process_job(self, job_id, processor):
                job = self._jobs.get(job_id)
                if not job:
                    return False
                success, attempts = self._retry_policy.run(processor, job.data)
                if success:
                    del self._jobs[job_id]
                    return True
                else:
                    job.retries = attempts
                    return False
        ```

    *   "FIFO order within the same priority": My `heapq` logic `(-priority, self._counter, job_id)` handles this correctly. Lower `_counter` (older job) comes first.
    *   `RetryPolicy` uses `fn(data)`: Yes.
    *   `RetryPolicy` returns `(success, attempts_made)`: Yes.
    *   `JobQueue` uses `RetryPolicy`: Yes.
    *   `JobQueue.process_job` signature: `(self, job_id: str, processor: Callable) -> bool`. Yes.
    *   `JobQueue.get_next_job` signature: `(self) -> tuple[str, dict] | None`. Yes.
    *   Standard library only: `dataclasses`, `heapq`, `typing` are standard.

    *   If `process_job` returns `False`, the job is still in `self._jobs`.
    *   But it's *not* in `self._queue` because `get_next_job` popped it.
    *   Is this a problem? The prompt doesn't specify if failed jobs should be requeued. It just says `process_job` returns `bool`.
    *   If a job is failed (exhausted retries), it's normally considered dead in many queue systems unless explicitly requeued.
    *   If the job was *not* exhausted (because `RetryPolicy` handles the attempts), `process_job` would have returned `True`.
    *   So `process_job` returns `False` ONLY when `attempts == max_attempts`.
    *   In this case, the job is dead. The fact it's still in `self._jobs` is fine, but it won't be returned by `get_next_job` again because it's not in the heap. This is a standard "poison pill" or "dead letter" scenario.

    *Wait*, I should make sure `RetryPolicy` is instantiated in `JobQueue`. The prompt says `JobQueue` orchestrates Jobs using `RetryPolicy`, but doesn't explicitly say `RetryPolicy` is passed in or created inside. I'll create it in `__init__`.

    One small detail: `Job` fields `id: str`, `data: dict`, `priority: int = 0`, `retries: int = 0`.
    The `RetryPolicy.run` signature is `run(self, fn: Callable, data) -> tuple[bool, int]`.

    `get_next_job`: returns `(id, data)`.
    If I use `heapq.heappop`, I'm removing it from the queue. If `process_job` is then called, it works. If `process_job` is *not* called, the job is lost from the queue. This is how queues work.

    Wait, if `get_next_job` is called and the job is "in progress", and `process_job` fails, should the job be lost?
    According to the prompt: `process_job` must return `bool`.
    If `RetryPolicy` exhausts `max_attempts`, `process_job` returns `False`.
    If it's `False`, the job is effectively done (failed).
    This seems to be the most logical implementation given the constraints.

    *   `Job`: `dataclass`.
    *   `RetryPolicy`: `max_attempts`, `run`.
    *   `JobQueue`: `add_job`, `process_job`, `get_next_job`.

    One more check on `get_next_job` logic:
    If I pop from the heap, the ID is removed from the queue.
    If `process_job` is successful, I remove it from `self.jobs`.
    If `process_job` fails, I keep it in `self.jobs` (to update `retries`) but it's already gone from `self.queue`.
    This means a failed job is never seen by `get_next_job` again. This is consistent.

    *One refinement*: `RetryPolicy.run` should be `run(self, fn: Callable, data) -> tuple[bool, int]`. The type hint for `data` isn't specified, but `dict` is used in `Job`.

    *Self-Correction on `JobQueue.__init__`*:
    The prompt doesn't specify how `RetryPolicy` is initialized. I'll default it to `RetryPolicy()` with default `max_attempts`.

    ```python
    from dataclasses import dataclass
    from typing import Callable, Any, Tuple, Dict, Optional
    import heapq

    @dataclass
    class Job:
        id: str
        data: dict
        priority: int = 0
        retries: int = 0

    class RetryPolicy:
        def __init__(self, max_attempts: int = 4):
            self.max_attempts = max_attempts

        def run(self, fn: Callable, data: Any) -> Tuple[bool, int]:
            attempts = 0
            for i in range(self.max_attempts):
                attempts += 1
                try:
                    fn(data)
                    return True, attempts
                except Exception:
                    continue
            return False, attempts

    class JobQueue:
        def __init__(self):
            self._jobs: Dict[str, Job] = {}
            self._queue = []
            self._counter = 0
            self._retry_policy = RetryPolicy()

        def add_job(self, job_id: str, data: dict, priority: int = 0) -> None:
            job = Job(id=job_id, data=data, priority=priority)
            self._jobs[job_id] = job
            heapq.heappush(self._queue, (-priority, self._counter, job_id))
            self._counter += 1

        def get_next_job(self) -> Optional[Tuple[str, dict]]:
            while self._queue:
                _, _, job_id = heapq.heappop(self._queue)
                if job_id in self._jobs:
                    job = self._jobs[job_id]
                    return job.id, job.data
            return None

        def process_job(self, job_id: str, processor: Callable) -> bool:
            job = self._jobs.get(job_id)
            if not job:
                return False
            
            success, attempts = self._retry_policy.run(processor, job.data)
            if success:
                del self._jobs[job_id]
                return True
            else:
                job.retries = attempts
                return False
    ```

    The prompt asks for `process_job(self, job_id: str, processor: Callable) -> bool`.
    My implementation uses `self._retry_policy.run(processor, job.data)`.
    This matches "must USE RetryPolicy".

    `priority` is `int`. Higher priority should come first.
    In `heapq`, elements are sorted ascending.
    To get "highest priority" (meaning highest number) first, I use `-priority`.
    So if priorities are `10, 5, 0`:
    Heap contains `-10, -5, 0`.
    `heappop` gives `-10`, then `-5`, then `0`.
    This is correct.
    The tie-breaker `self._counter` ensures FIFO.

    Final check on `Job` dataclass:
    `id: str`, `data: dict`, `priority: int = 0`, `retries: int = 0`.
    Matches perfectly.

    One detail: the prompt says `run(self, fn: Callable, data)`. It doesn't specify the type of `data`, but `Job.data` is a `dict`.

    Final code looks clean.

    Wait, one thing: `from typing import Callable, Any, Tuple, Dict, Optional`.
    Standard library imports are fine.

    ```python
    from dataclasses import dataclass
    from typing import Callable, Any, Tuple, Dict, Optional
    import heapq

    @dataclass
    class Job:
        id: str
        data: dict
        priority: int = 0
        retries: int = 0

    class RetryPolicy:
        def __init__(self, max_attempts: int = 4):
            self.max_attempts = max_attempts

        def run(self, fn: Callable, data: Any) -> Tuple[bool, int]:
            attempts = 0
            while attempts < self.max_attempts:
                attempts += 1
                try:
                    fn(data)
                    return True, attempts
                except Exception:
                    pass
            return False, attempts

    class JobQueue:
        def __init__(self):
            self._jobs: Dict[str, Job] = {}
            self._queue = []
            self._counter = 0
            self._retry_policy = RetryPolicy()

        def add_job(self, job_id: str, data: dict, priority: int = 0) -> None:
            job = Job(id=job_id, data=data, priority=priority)
            self._jobs[job_id] = job
            # Use negative priority for max-heap behavior with heapq (min-heap)
            # Use counter for FIFO within same priority
            heapq.heappush(self._queue, (-priority, self._counter, job_id))
            self._counter += 1

        def get_next_job(self) -> Optional[Tuple[str, dict]]:
            while self._queue:
                _, _, job_id = heapq.heappop(self._queue)
                if job_id in self._jobs:
                    job = self._jobs[job_id]
                    return job.id, job.data
            return None

        def process_job(self, job_id: str, processor: Callable) -> bool:
            job = self._jobs.get(job_id)
            if not job:
                return False
            
            success, attempts = self._retry_policy.run(processor, job.data)
            if success:
                del self._jobs[job_id]
                return True
            else:
                job.retries = attempts
                return False

    ```
    The `while attempts < self.max_attempts` logic is slightly cleaner than `for i in range`.

    Wait, `process_job` should be able to use any `RetryPolicy`. The instruction says `JobQueue` orchestrates Jobs using `RetryPolicy`. It doesn't say `RetryPolicy` is an attribute of `JobQueue` that is pre-configured, but it's the only way `process_job` can work without passing it in. I'll stick with the `self._retry_policy = RetryPolicy()` in `__init__`.

    Check signature: `get_next_job(self) -> tuple[str, dict] | None`.
    Python 3.10+ allows `|`. I'll use `Optional[Tuple[str, dict]]` or just `tuple[str, dict] | None` to be safe, but since I'm using `dataclass`, I should stick to modern syntax if possible. Actually, the prompt uses `tuple[str, dict] | None`, so I'll use that.

    Final check on `RetryPolicy.run`:
    `attempts` is incremented before calling `fn(data)`.
    If `max_attempts` is 4:
    - loop 1: `attempts=1`, `fn` called. If fails, continue.
    - loop 2: `attempts=2`, `fn` called. If fails, continue.
    - loop 3: `attempts=3`, `fn` called. If fails, continue.
    - loop 4: `attempts=4`, `fn` called. If fails, loop ends.
    Returns `False, 4`.
    Correct.
</reasoning_content>

<content>
```python
from dataclasses import dataclass
from typing import Callable, Any, Tuple
import heapq

@dataclass
class Job:
    id: str
    data: dict
    priority: int = 0
    retries: int = 0

class RetryPolicy:
    def __init__(self, max_attempts: int = 4):
        self.max_attempts = max_attempts

    def run(self, fn: Callable, data: Any) -> tuple[bool, int]:
        attempts = 0
        while attempts < self.max_attempts:
            attempts += 1
            try:
                fn(data)
                return True, attempts
            except Exception:
                pass
        return False, attempts

class JobQueue:
    def __init__(self):
        self._jobs: dict[str, Job] = {}
        self._queue = []
        self._counter = 0
        self._retry_policy = RetryPolicy()

    def add_job(self, job_id: str, data: dict, priority: int = 0) -> None:
        job = Job(id=job_id, data=data, priority=priority)
        self._jobs[job_id] = job
        # Use negative priority for max-heap behavior with heapq (min-heap)
        # Use counter as tie-breaker for FIFO within the same priority
        heapq.heappush(self._queue, (-priority, self._counter, job_id))
        self._counter += 1

    def get_next_job(self) -> tuple[str, dict] | None:
        while self._queue:
            _, _, job_id = heapq.heappop(self._queue)
            if job_id in self._jobs:
                job = self._jobs[job_id]
                return job.id, job.data
        return None

    def process_job(self, job_id: str, processor: Callable) -> bool:
        job = self._jobs.get(job_id)
        if not job:
            return False
        
        success, attempts = self._retry_policy.run(processor, job.data)
        if success:
            del self._jobs[job_id]
            return True
        else:
            job.retries = attempts
            return False
```
</content>