# 建树修复管线三项改造 实现计划

> **For agentic workers:** REQUIRED SUB-SKILL: Use subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** 根治遥测主键冲突导致的熔断误触发，为修复管线增加视频级断点续跑和 16 路并发。

**Architecture:** 三层改动互相独立——(1) adapters 层遥测防御加固 + call_id 根因修复，对所有 GovernedLLMClient 使用方生效；(2) detector 扩展 L2/L1 空字段检测，零 LLM 成本；(3) tools/repair_trees.py 编排层并发 + 断点续跑。

**Tech Stack:** Python 3.11, asyncio, sqlite3, loguru

**本计划不涉及核心算法迁移，保真校验不适用。**

---

### Task 1: 遥测防御性加固

**Files:**
- Modify: `adapters/telemetry.py:47-55`（`_INSERT_SQL`）、`adapters/telemetry.py:69-114`（`_write`）
- Test: `tests/unit/test_telemetry.py`

- [ ] **Step 1: 写失败测试 — 重复 call_id 写入不抛异常**

在 `tests/unit/test_telemetry.py` 末尾追加：

```python
@pytest.mark.asyncio
async def test_duplicate_call_id_does_not_raise(recorder, db_path):
    """重复 call_id 写入应静默忽略（INSERT OR IGNORE），不抛异常。"""
    kwargs = _make_call_kwargs()
    await recorder.record_llm_call(**kwargs)
    # 第二次用相同 call_id 写入不应抛异常
    await recorder.record_llm_call(**kwargs)

    conn = sqlite3.connect(str(db_path))
    rows = conn.execute("SELECT COUNT(*) FROM llm_calls").fetchone()
    conn.close()
    assert rows[0] == 1  # 只有一条记录
```

- [ ] **Step 2: 运行测试验证失败**

运行: `conda activate Video-Tree-TRM && pytest tests/unit/test_telemetry.py::test_duplicate_call_id_does_not_raise -v`
预期: FAIL — `sqlite3.IntegrityError: UNIQUE constraint failed`

- [ ] **Step 3: 写失败测试 — DB 错误不冒泡**

```python
@pytest.mark.asyncio
async def test_db_error_does_not_propagate(tmp_path):
    """SQLite 写入失败时 record_llm_call 应静默降级（logger.warning），不抛异常。"""
    # 用目录路径当 db_path —— sqlite3.connect 对目录名会在真正操作时报错
    bad_recorder = SQLiteTelemetryRecorder(db_path=tmp_path / "nonexistent_dir" / "bad.db")
    kwargs = _make_call_kwargs()
    # 不应抛异常
    await bad_recorder.record_llm_call(**kwargs)
```

- [ ] **Step 4: 运行测试验证失败**

运行: `conda activate Video-Tree-TRM && pytest tests/unit/test_telemetry.py::test_db_error_does_not_propagate -v`
预期: FAIL — `sqlite3.OperationalError: unable to open database file`

- [ ] **Step 5: 写失败测试 — 并发写不报锁错**

```python
@pytest.mark.asyncio
async def test_concurrent_writes_no_lock_error(recorder, db_path):
    """16 路并发 record_llm_call 应全部成功，无 database is locked 错误。"""
    import asyncio

    tasks = []
    for _ in range(16):
        kwargs = _make_call_kwargs()  # 每次生成不同 call_id
        tasks.append(recorder.record_llm_call(**kwargs))
    await asyncio.gather(*tasks)

    conn = sqlite3.connect(str(db_path))
    count = conn.execute("SELECT COUNT(*) FROM llm_calls").fetchone()[0]
    conn.close()
    assert count == 16
```

- [ ] **Step 6: 运行测试验证失败**

运行: `conda activate Video-Tree-TRM && pytest tests/unit/test_telemetry.py::test_concurrent_writes_no_lock_error -v`
预期: 可能 PASS（取决于并发时序）或 FAIL — `database is locked`

- [ ] **Step 7: 实现遥测三层加固**

修改 `adapters/telemetry.py`：

1. `_INSERT_SQL`: `INSERT INTO` → `INSERT OR IGNORE INTO`
2. `_write` 方法: `sqlite3.connect()` 后追加 WAL + busy_timeout pragma，整个方法体包 `try/except sqlite3.Error`

```python
_INSERT_SQL = """
    INSERT OR IGNORE INTO llm_calls (
        call_id, parent_call_id, session_id,
        model_name, provider, messages, response, thinking,
        prompt_tokens, completion_tokens, latency_ms,
        ttft_ms, max_inter_token_ms,
        cache_hit, error
    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
    """

def _write(self, *, call_id, parent_call_id, session_id, model_name,
           provider, messages, response, thinking, prompt_tokens,
           completion_tokens, latency_ms, ttft_ms, max_inter_token_ms,
           cache_hit, error):
    """同步写入一条 LLM 调用记录到 SQLite。"""
    try:
        conn = sqlite3.connect(str(self._db_path), timeout=10.0)
        try:
            conn.execute("PRAGMA journal_mode=WAL")
            conn.execute("PRAGMA busy_timeout=5000")
            self._ensure_table(conn)
            conn.execute(
                self._INSERT_SQL,
                (call_id, parent_call_id, session_id, model_name,
                 provider, messages, response, thinking, prompt_tokens,
                 completion_tokens, latency_ms, ttft_ms,
                 max_inter_token_ms, int(cache_hit), error),
            )
            conn.commit()
        finally:
            conn.close()
    except sqlite3.Error as exc:
        logger.warning("遥测写入失败（已降级），call_id={}: {}", call_id, exc)
```

在文件顶部添加 `from loguru import logger`。

- [ ] **Step 8: 运行全部遥测测试验证通过**

运行: `conda activate Video-Tree-TRM && pytest tests/unit/test_telemetry.py -v`
预期: 全部 PASS（含三个新测试）

- [ ] **Step 9: 提交**

```bash
git add adapters/telemetry.py tests/unit/test_telemetry.py
git commit -m "fix(telemetry): INSERT OR IGNORE + WAL + try/except 三层防御加固

根治遥测写入主键冲突(UNIQUE constraint)和并发写锁(database is locked)
导致的异常冒泡，遥测侧信道错误不再污染 LLM 重试链。"
```

---

### Task 2: GovernedLLMClient call_id 移入重试循环

**Files:**
- Modify: `adapters/llm.py:298-299`（`call_id` 生成位置）、`adapters/llm.py:358-369`（成功路径 `response.call_id`）
- Test: `tests/unit/test_governed_llm.py`

- [ ] **Step 1: 更新现有测试断言 — call_id 应每次重试独立**

`test_governed_llm.py:200-202` 当前断言 `assert len(call_ids) == 1`（所有重试记录共用一个 call_id），需要反转为 `assert len(call_ids) == 3`（每次 attempt 独立）。

找到 `test_transient_error_retries_and_records_telemetry` 中：

```python
    # 所有遥测记录应使用同一个 call_id（Important 2 修复验证）
    call_ids = {c["call_id"] for c in telemetry.calls}
    assert len(call_ids) == 1
```

替换为：

```python
    # 每次 attempt 应使用独立的 call_id（根因修复：防遥测主键冲突）
    call_ids = {c["call_id"] for c in telemetry.calls}
    assert len(call_ids) == 3  # 2 次失败 + 1 次成功 = 3 个独立 call_id
```

- [ ] **Step 2: 运行测试验证失败**

运行: `conda activate Video-Tree-TRM && pytest tests/unit/test_governed_llm.py::test_transient_error_retries_and_records_telemetry -v`
预期: FAIL — `assert 1 == 3`（当前还是共用一个 call_id）

- [ ] **Step 3: 将 call_id 生成移入重试循环**

修改 `adapters/llm.py`。将第 298-299 行的 `call_id = str(uuid4())` **从重试循环外移入循环内**：

找到（约 line 298-338）：

```python
        # ② call_id 生成
        call_id = str(uuid4())

        # ③ 缓存查询（cache 为 None 时跳过）
        cached = await self._cache.get(self._model, messages) if self._cache is not None else None
        if cached is not None:
            ...
            return response

        # ④ 重试循环 + 流式消费
        last_exc: Exception | None = None
        for attempt in range(self._max_retries):
```

改为：

```python
        # ② 缓存查询（cache 为 None 时跳过）— call_id 在缓存路径独立生成
        cached = await self._cache.get(self._model, messages) if self._cache is not None else None
        if cached is not None:
            cache_call_id = str(uuid4())
            response = LLMResponse(
                ...
                call_id=cache_call_id,
            )
            await self._telemetry.record_llm_call(
                call_id=cache_call_id,
                ...
            )
            return response

        # ③ 重试循环 + 流式消费（每次 attempt 独立 call_id）
        last_exc: Exception | None = None
        for attempt in range(self._max_retries):
            call_id = str(uuid4())
            attempt_start = time.monotonic()
```

注意：缓存命中路径的 `call_id` 改为独立的 `cache_call_id`（保持在循环外生成，因为不走重试）。

- [ ] **Step 4: 运行全部 GovernedLLM 测试验证通过**

运行: `conda activate Video-Tree-TRM && pytest tests/unit/test_governed_llm.py -v`
预期: 全部 PASS

- [ ] **Step 5: 提交**

```bash
git add adapters/llm.py tests/unit/test_governed_llm.py
git commit -m "fix(llm): call_id 移入重试循环，每 attempt 独立

消除重试时遥测主键冲突的根因。每次 attempt 独立记录，
parent_call_id 不受影响（循环外固定），更利于事后诊断重试轨迹。"
```

---

### Task 3: 检测器扩展 L2/L1 空字段

**Files:**
- Modify: `app/tree/repair/detector.py:57-68`（L1 循环）、`app/tree/repair/detector.py:73-84`（L2 循环）
- Test: `tests/unit/test_repair_detector.py`

- [ ] **Step 1: 写失败测试 — L2 event_description 为空触发 empty_field**

在 `tests/unit/test_repair_detector.py` 追加：

```python
def test_detects_l2_empty_event_description():
    """L2 event_description 为空应报 empty_field。"""
    l3 = L3Node(
        id="l1_0_l2_0_l3_0",
        card=L3Card("正常帧", ["实体"], ["动作"], [], "居中", {}),
        timestamp=1.0,
    )
    l2 = L2Node(
        id="l1_0_l2_0",
        card=L2Card("", [], [], [], [], "", None),  # event_description 为空
        time_range=(0.0, 10.0),
        children=[l3],
    )
    l1 = L1Node(
        id="l1_0",
        card=L1Card("正常场景", "", [], [], [], [], ""),
        time_range=(0.0, 10.0),
        children=[l2],
    )
    index = TreeIndex(metadata=IndexMeta("/t.mp4", "video"), roots=[l1])
    issues = detect_issues(index)
    l2_empties = [i for i in issues if i.level == 2 and i.issue_type == "empty_field"]
    assert len(l2_empties) == 1
    assert "event_description" in l2_empties[0].details
```

- [ ] **Step 2: 写失败测试 — L1 scene_summary 为空触发 empty_field**

```python
def test_detects_l1_empty_scene_summary():
    """L1 scene_summary 为空应报 empty_field。"""
    l3 = L3Node(
        id="l1_0_l2_0_l3_0",
        card=L3Card("正常帧", ["实体"], ["动作"], [], "居中", {}),
        timestamp=1.0,
    )
    l2 = L2Node(
        id="l1_0_l2_0",
        card=L2Card("正常事件", [], [], [], [], "", None),
        time_range=(0.0, 10.0),
        children=[l3],
    )
    l1 = L1Node(
        id="l1_0",
        card=L1Card("", "", [], [], [], [], ""),  # scene_summary 为空
        time_range=(0.0, 10.0),
        children=[l2],
    )
    index = TreeIndex(metadata=IndexMeta("/t.mp4", "video"), roots=[l1])
    issues = detect_issues(index)
    l1_empties = [i for i in issues if i.level == 1 and i.issue_type == "empty_field"]
    assert len(l1_empties) == 1
    assert "scene_summary" in l1_empties[0].details
```

- [ ] **Step 3: 运行测试验证失败**

运行: `conda activate Video-Tree-TRM && pytest tests/unit/test_repair_detector.py::test_detects_l2_empty_event_description tests/unit/test_repair_detector.py::test_detects_l1_empty_scene_summary -v`
预期: 两个都 FAIL

- [ ] **Step 4: 实现 L2/L1 空字段检测**

修改 `app/tree/repair/detector.py` 的 `detect_issues` 函数：

在 L2 的 `if not l2.children:` 检查之后（`continue` 之前），插入 L2 空字段检测：

```python
        for l2 in l1.children:
            # L2: event_description 不为空
            if not l2.card.event_description:
                issues.append(
                    NodeIssue(
                        node_id=l2.id,
                        level=2,
                        issue_type="empty_field",
                        details="L2 节点字段为空: event_description",
                    )
                )

            # L2: children 不为空
            if not l2.children:
                ...
```

在 L1 的 `if not l1.children:` 检查之前，插入 L1 空字段检测：

```python
    for l1 in index.roots:
        # L1: scene_summary 不为空
        if not l1.card.scene_summary:
            issues.append(
                NodeIssue(
                    node_id=l1.id,
                    level=1,
                    issue_type="empty_field",
                    details="L1 节点字段为空: scene_summary",
                )
            )

        # L1: children 不为空
        if not l1.children:
            ...
```

同步更新 docstring 的"检查项"列表。

- [ ] **Step 5: 运行全部 detector 测试验证通过**

运行: `conda activate Video-Tree-TRM && pytest tests/unit/test_repair_detector.py -v`
预期: 全部 PASS

- [ ] **Step 6: 提交**

```bash
git add app/tree/repair/detector.py tests/unit/test_repair_detector.py
git commit -m "feat(detector): 扩展空字段检测到 L2 event_description / L1 scene_summary

断点续跑判据需要 L2/L1 层的 empty_field 检测。零 LLM 成本。"
```

---

### Task 4: 断点续跑 — progress 文件管理

**Files:**
- Modify: `tools/repair_trees.py`（新增 progress 读写函数 + 跳过逻辑）
- Test: `tests/unit/test_repair_progress.py`（新建）

- [ ] **Step 1: 写失败测试 — progress 读写与幂等性**

新建 `tests/unit/test_repair_progress.py`：

```python
"""修复管线断点续跑 progress 管理测试。"""
from __future__ import annotations

import asyncio
import json
from pathlib import Path

import pytest


def test_load_progress_missing_file(tmp_path):
    """progress 文件不存在时返回空集合。"""
    from tools.repair_trees import load_progress

    result = load_progress(tmp_path / "nonexistent.json")
    assert result == set()


def test_load_progress_valid_file(tmp_path):
    """正常读取已有 progress 文件。"""
    from tools.repair_trees import load_progress

    path = tmp_path / "progress.json"
    path.write_text(json.dumps({"finished_video_ids": ["vid_a", "vid_b"]}))
    result = load_progress(path)
    assert result == {"vid_a", "vid_b"}


def test_load_progress_corrupted_file(tmp_path):
    """损坏的 JSON 文件返回空集合（不抛异常）。"""
    from tools.repair_trees import load_progress

    path = tmp_path / "progress.json"
    path.write_text("{invalid json")
    result = load_progress(path)
    assert result == set()


@pytest.mark.asyncio
async def test_save_progress_atomic(tmp_path):
    """save_progress 原子写入，并发调用不丢失更新。"""
    from tools.repair_trees import save_progress

    path = tmp_path / "progress.json"
    lock = asyncio.Lock()

    await save_progress(path, lock, "vid_a")
    await save_progress(path, lock, "vid_b")

    data = json.loads(path.read_text())
    assert set(data["finished_video_ids"]) == {"vid_a", "vid_b"}


@pytest.mark.asyncio
async def test_save_progress_concurrent(tmp_path):
    """16 路并发 save_progress 不丢失更新。"""
    from tools.repair_trees import save_progress

    path = tmp_path / "progress.json"
    lock = asyncio.Lock()

    tasks = [save_progress(path, lock, f"vid_{i}") for i in range(16)]
    await asyncio.gather(*tasks)

    data = json.loads(path.read_text())
    assert len(data["finished_video_ids"]) == 16


def test_should_skip_finished(tmp_path):
    """已在 finished 集合中的视频应跳过。"""
    from tools.repair_trees import should_skip_video

    finished = {"vid_a", "vid_b"}
    assert should_skip_video("vid_a", finished, reaggregate_all=False) is True
    assert should_skip_video("vid_c", finished, reaggregate_all=False) is False


def test_should_skip_reaggregate_all_forces_rerun(tmp_path):
    """--reaggregate-all 标志强制不跳过。"""
    from tools.repair_trees import should_skip_video

    finished = {"vid_a"}
    assert should_skip_video("vid_a", finished, reaggregate_all=True) is False
```

- [ ] **Step 2: 运行测试验证失败**

运行: `conda activate Video-Tree-TRM && pytest tests/unit/test_repair_progress.py -v`
预期: FAIL — `ImportError: cannot import name 'load_progress'`

- [ ] **Step 3: 在 repair_trees.py 实现 progress 管理函数**

在 `tools/repair_trees.py` 的 `_build_clients()` 函数之前插入：

```python
# ---------------------------------------------------------------------------
# 断点续跑 — progress 文件管理
# ---------------------------------------------------------------------------

PROGRESS_FILE = "repair_progress.json"


def load_progress(path: Path) -> set[str]:
    """读取 progress 文件，返回已完成视频 ID 集合。

    参数:
        path: progress JSON 文件路径。

    返回:
        已完成视频 ID 集合。文件不存在或损坏时返回空集。
    """
    if not path.exists():
        return set()
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
        return set(data.get("finished_video_ids", []))
    except (json.JSONDecodeError, KeyError, TypeError):
        logger.warning("progress 文件损坏，忽略: {}", path)
        return set()


async def save_progress(path: Path, lock: asyncio.Lock, vid: str) -> None:
    """原子追加一个视频 ID 到 progress 文件。

    参数:
        path: progress JSON 文件路径。
        lock: asyncio.Lock，防并发读改写丢更新。
        vid: 要追加的视频 ID。
    """
    async with lock:
        finished = load_progress(path)
        finished.add(vid)
        tmp = path.with_suffix(".tmp")
        tmp.write_text(
            json.dumps({"finished_video_ids": sorted(finished)}, ensure_ascii=False, indent=2),
            encoding="utf-8",
        )
        os.replace(str(tmp), str(path))


def should_skip_video(vid: str, finished: set[str], *, reaggregate_all: bool) -> bool:
    """判断是否跳过该视频。

    参数:
        vid: 视频 ID。
        finished: progress 中已完成的视频 ID 集合。
        reaggregate_all: --reaggregate-all 标志。

    返回:
        True 表示跳过。
    """
    if reaggregate_all:
        return False
    return vid in finished
```

- [ ] **Step 4: 运行测试验证通过**

运行: `conda activate Video-Tree-TRM && pytest tests/unit/test_repair_progress.py -v`
预期: 全部 PASS

- [ ] **Step 5: 提交**

```bash
git add tools/repair_trees.py tests/unit/test_repair_progress.py
git commit -m "feat(repair): 断点续跑 progress 文件管理

load_progress / save_progress(asyncio.Lock + os.replace 原子写入)
/ should_skip_video。支持并发安全的读改写和 --reaggregate-all 兜底。"
```

---

### Task 5: 并发编排 + CLI 参数 + 熔断阈值适配

**Files:**
- Modify: `tools/repair_trees.py`（`main_async` 并发化 + `parse_args` 新参数 + `_build_clients` 阈值适配）
- Modify: `.env.example:44`（阈值注释更新）
- Test: 手动集成测试（`--dry-run` 模式验证并发 + 跳过逻辑）

- [ ] **Step 1: 更新 parse_args 增加 --concurrency 和 --reaggregate-all**

修改 `tools/repair_trees.py` 的 `parse_args` 函数，在 `--dry-run` 之后追加：

```python
    parser.add_argument(
        "--concurrency",
        type=int,
        default=16,
        help="并发修复视频数（默认: 16）",
    )
    parser.add_argument(
        "--reaggregate-all",
        action="store_true",
        help="强制全量重聚合，忽略 progress 文件",
    )
```

- [ ] **Step 2: 更新 _build_clients 接受 concurrency 参数适配熔断阈值**

修改 `_build_clients` 签名为 `_build_clients(concurrency: int = 16)`。

在读取 `breaker_threshold` 后追加：

```python
    breaker_threshold = int(os.getenv("LLM_CIRCUIT_BREAKER_THRESHOLD", "5"))
    breaker_threshold = max(breaker_threshold, concurrency * 2)
```

传入两个 `CircuitBreaker` 实例时使用适配后的 `breaker_threshold`。

- [ ] **Step 3: 重写 main_async 实现并发 + 断点续跑**

将 `main_async` 的串行 for 循环替换为 Semaphore 并发编排：

```python
async def main_async(args: argparse.Namespace) -> None:
    """异步主流程：并发修复视频。"""
    videos_dir = Path(args.videos_dir)
    srt_dir = Path(args.srt_dir)
    questions_dir = Path(args.questions_dir)
    concurrency = args.concurrency
    reaggregate_all = args.reaggregate_all

    # 扫描所有视频
    vid_dirs = sorted(
        d for d in videos_dir.iterdir()
        if d.is_dir() and (d / "tree.json").exists()
    )
    logger.info("发现 {} 个视频", len(vid_dirs))

    # 加载 progress
    progress_path = PROJECT_ROOT / "logs" / PROGRESS_FILE
    finished = load_progress(progress_path)
    if finished:
        logger.info("已完成 {} 个视频（从 progress 文件加载）", len(finished))

    if args.dry_run:
        logger.info("=== DRY RUN 模式：仅检测不修复 ===")

    # 构建客户端（dry_run 模式不需要）
    llm, vlm = (None, None) if args.dry_run else _build_clients(concurrency)

    # 过滤跳过的视频
    pending = []
    skipped_count = 0
    for vid_dir in vid_dirs:
        vid = vid_dir.name
        if should_skip_video(vid, finished, reaggregate_all=reaggregate_all):
            skipped_count += 1
            continue
        pending.append(vid_dir)

    if skipped_count:
        logger.info("跳过 {} 个已完成视频，待处理 {} 个", skipped_count, len(pending))

    # 并发编排
    sem = asyncio.Semaphore(concurrency)
    progress_lock = asyncio.Lock()
    all_stats: list[dict] = []
    stats_lock = asyncio.Lock()
    start_time = time.time()
    completed = 0

    async def _process(vid_dir: Path) -> None:
        nonlocal completed
        async with sem:
            vid = vid_dir.name
            tree_path = vid_dir / "tree.json"
            frames_dir = vid_dir

            logger.info("开始修复 {}", vid)

            stats = await _repair_one_video(
                vid, tree_path, frames_dir, srt_dir, questions_dir,
                llm, vlm, dry_run=args.dry_run,
            )

            async with stats_lock:
                all_stats.append(stats)
                completed += 1

            # 无 error 且非 dry_run 才记 finished
            if stats["error"] is None and not args.dry_run:
                await save_progress(progress_path, progress_lock, vid)

            # 进度日志
            if completed % 10 == 0:
                elapsed = time.time() - start_time
                rate = completed / elapsed * 60
                logger.info(
                    "进度: {}/{}, 已用 {:.0f}s, 速率 {:.1f} 视频/分钟",
                    completed, len(pending), elapsed, rate,
                )

    tasks = [asyncio.create_task(_process(vd)) for vd in pending]
    await asyncio.gather(*tasks)

    # 最终汇总
    elapsed = time.time() - start_time
    total_issues = sum(s["issues_found"] for s in all_stats)
    total_repaired = sum(s["l3_repaired"] for s in all_stats)
    total_injected = sum(s["facts_injected"] for s in all_stats)
    total_errors = sum(1 for s in all_stats if s["error"])

    logger.info("=" * 60)
    logger.info("修复完成")
    logger.info("  视频总数: {}", len(all_stats))
    logger.info("  跳过数: {}", skipped_count)
    logger.info("  问题总数: {}", total_issues)
    logger.info("  L3 修复数: {}", total_repaired)
    logger.info("  事实注入数: {}", total_injected)
    logger.info("  失败数: {}", total_errors)
    logger.info("  总耗时: {:.0f}s", elapsed)
    logger.info("  并发数: {}", concurrency)
    logger.info("=" * 60)

    if total_errors > 0:
        logger.warning("以下视频修复失败:")
        for s in all_stats:
            if s["error"]:
                logger.warning("  {}: {}", s["vid"], s["error"])
```

- [ ] **Step 4: 更新 .env.example 熔断阈值注释**

将 `.env.example` 中：

```
LLM_CIRCUIT_BREAKER_THRESHOLD=5
```

改为：

```
LLM_CIRCUIT_BREAKER_THRESHOLD=5  # 实际阈值 = max(此值, concurrency*2)
```

- [ ] **Step 5: dry-run 模式冒烟测试**

运行: `conda activate Video-Tree-TRM && python tools/repair_trees.py --dry-run --concurrency 4 2>&1 | head -30`
预期: 看到"发现 N 个视频"、"跳过 M 个已完成视频"、并发日志，无报错

- [ ] **Step 6: 提交**

```bash
git add tools/repair_trees.py .env.example
git commit -m "feat(repair): asyncio.Semaphore 并发 + 断点续跑 + CLI 参数

--concurrency 默认 16，--reaggregate-all 强制全量重聚合。
Semaphore 限视频并发数，视频内四步串行。progress 文件
asyncio.Lock + os.replace 原子写入。熔断阈值 max(.env, concurrency*2)。"
```

---

### Task 6: Lint + 全量测试 + 最终验证

**Files:**
- 无新文件

- [ ] **Step 1: ruff 格式化与检查**

运行:
```bash
conda activate Video-Tree-TRM && ruff format adapters/telemetry.py adapters/llm.py app/tree/repair/detector.py tools/repair_trees.py
conda activate Video-Tree-TRM && ruff check adapters/telemetry.py adapters/llm.py app/tree/repair/detector.py tools/repair_trees.py --fix
```
预期: 无错误

- [ ] **Step 2: 全量测试**

运行: `conda activate Video-Tree-TRM && pytest tests/unit/ -v --tb=short`
预期: 全部 PASS

- [ ] **Step 3: 提交 lint 修正（如有）**

```bash
git add -u
git commit -m "style: ruff format adapters + detector + repair_trees"
```

- [ ] **Step 4: 确认改动文件范围**

运行: `git diff --stat main`
预期改动文件：

| 文件 | 性质 |
|------|------|
| `adapters/telemetry.py` | 修改 |
| `adapters/llm.py` | 修改 |
| `app/tree/repair/detector.py` | 修改 |
| `tools/repair_trees.py` | 修改 |
| `.env.example` | 修改 |
| `tests/unit/test_telemetry.py` | 修改 |
| `tests/unit/test_governed_llm.py` | 修改 |
| `tests/unit/test_repair_detector.py` | 修改 |
| `tests/unit/test_repair_progress.py` | 新建 |
