### Task 3: Failed tasks are scheduler-"completed" — dependents dispatch against missing parent work

**Root cause (verified):** `dispatcher.py:465-466` records **any** dict-returning result as completed (`result.completed[task_id] = task_result or {}`; `self._scheduler.mark_completed(task_id)`) without inspecting `task_result["success"]`. The `task_handler` returns `{"success": False, "output": …}` on agent failure (`task_handler.py:1964-1968`), yet the scheduler node is marked completed, `are_dependencies_satisfied` passes, and dependents dispatch in the same run against a target lacking the parent's work. The documented "dependents become blocked on failure" invariant does not hold.

**Note on scope:** the fatal-auth (`dispatcher.py:440-449`) and deferred (`dispatcher.py:450-464`) branches already handle their special cases *before* line 465 and must be preserved. This task only changes the final `else` outcome.

**Files:**
- Modify: `claw_forge/orchestrator/dispatcher.py:465-469`
- Test: `tests/orchestrator/test_dispatcher_failed_result.py` (create)

**Interfaces:**
- Consumes: `Scheduler.mark_failed(task_id)` (exists, `scheduler.py:57`); `DispatchResult.failed: dict[str, str]` (exists, `dispatcher.py:197`).
- Produces: unchanged public surface; behaviour change only.

- [ ] **Step 1: Write the failing test**

```python
# tests/orchestrator/test_dispatcher_failed_result.py
import pytest
from claw_forge.orchestrator.dispatcher import Dispatcher
from claw_forge.state.scheduler import TaskNode


@pytest.mark.asyncio
async def test_dict_result_with_success_false_marks_failed_not_completed():
    async def handler(node):
        return {"success": False, "output": "agent gave up"}

    d = Dispatcher(handler=handler, max_concurrency=1)
    d.add_task(TaskNode(id="t1", description="x", depends_on=[]))
    result = await d.run()

    assert "t1" in result.failed
    assert "t1" not in result.completed
    assert d._scheduler.get_status("t1") == "failed"


@pytest.mark.asyncio
async def test_dict_result_with_success_true_still_completes():
    async def handler(node):
        return {"success": True, "output": "done"}

    d = Dispatcher(handler=handler, max_concurrency=1)
    d.add_task(TaskNode(id="t1", description="x", depends_on=[]))
    result = await d.run()

    assert "t1" in result.completed
    assert "t1" not in result.failed
```

(If `TaskNode`'s constructor differs, mirror the shape used in `tests/orchestrator/test_dispatcher.py` — read that file first for the exact fixture pattern and `get_status` accessor name.)

- [ ] **Step 2: Run test to verify it fails**

Run: `uv run pytest tests/orchestrator/test_dispatcher_failed_result.py -v`
Expected: FAIL — `t1` appears in `completed`, not `failed`.

- [ ] **Step 3: Branch on `success` in the wave-result handler**

In `claw_forge/orchestrator/dispatcher.py`, replace lines 465-469 (the block after the `_is_deferred` `continue`):

```python
                        if (
                            isinstance(task_result, dict)
                            and task_result.get("success") is False
                        ):
                            result.failed[task_id] = str(
                                task_result.get("output", "task failed")
                            )
                            self._scheduler.mark_failed(task_id)
                        else:
                            result.completed[task_id] = task_result or {}
                            self._scheduler.mark_completed(task_id)
                            # Notify reviewer of feature completion
                            if self._reviewer is not None:
                                self._reviewer.notify_feature_completed()
```

- [ ] **Step 4: Run the tests to verify they pass**

Run: `uv run pytest tests/orchestrator/test_dispatcher_failed_result.py tests/orchestrator/test_dispatcher.py -v`
Expected: PASS. Watch existing dispatcher tests — some may have asserted the old (buggy) "failed dict lands in completed" behaviour; if any break, they were pinning the bug — update them to expect `failed` and note it in the commit body.

- [ ] **Step 5: Verify the run_cli re-dispatch mirror**

`select_redispatch_pending` (`run_cli.py`) selects DB-`status="pending"` rows and is already status-authoritative (`run_cli.py:1388-1397`), so a now-`failed` task will not be silently re-run as satisfied. Confirm no code marks the in-memory node `merged_to_target_branch=True` on the failed path. Run: `uv run pytest tests/orchestrator/ -q`
Expected: PASS.

- [ ] **Step 6: Lint + commit**

Run: `uv run ruff check claw_forge/ tests/`
```bash
git add claw_forge/orchestrator/dispatcher.py tests/orchestrator/test_dispatcher_failed_result.py
git commit -m "fix(dispatcher): mark success=False dict results as failed, not completed (merge-gating invariant)"
```

---

