---
phase: 01-telegram-thread-leak
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - python/opendeviationbar/logging.py
  - python/opendeviationbar/config/network.py
  - tests/test_telegram_log_sink.py
autonomous: true
requirements:
  - STAB-01
  - STAB-02

must_haves:
  truths:
    - "Telegram log sink uses a bounded thread pool (max 2 threads), never spawns unbounded threads"
    - "Kintsugi batch repairs with Telegram SSL errors do not crash with 'can't start new thread'"
    - "Direct send_telegram() callers (sidecar, kintsugi notifications) are unaffected"
  artifacts:
    - path: "python/opendeviationbar/logging.py"
      provides: "Bounded ThreadPoolExecutor for Telegram log sink"
      contains: "ThreadPoolExecutor"
    - path: "python/opendeviationbar/config/network.py"
      provides: "Separate log sink timeout config"
      contains: "log_sink_timeout_s"
    - path: "tests/test_telegram_log_sink.py"
      provides: "Thread pool behavior tests"
      contains: "test_bounded_thread_pool"
  key_links:
    - from: "python/opendeviationbar/logging.py"
      to: "concurrent.futures.ThreadPoolExecutor"
      via: "module-level executor with max_workers=2"
      pattern: "ThreadPoolExecutor.*max_workers.*2"
    - from: "python/opendeviationbar/logging.py"
      to: "python/opendeviationbar/notify/telegram.py"
      via: "_send() static method calls send_telegram()"
      pattern: "send_telegram"
---

<objective>
Replace the unbounded `threading.Thread` fire-and-forget pattern in `_TelegramLogSink` with a module-level `ThreadPoolExecutor(max_workers=2)` to prevent thread exhaustion during high-volume logging (kintsugi 30-shard batch repairs with SSL errors). Add a dedicated 3s timeout for the log sink path (vs 10s for direct notification calls).

Purpose: Eliminate the P0 stability issue where kintsugi crashes with `RuntimeError: can't start new thread` when many WARNING/ERROR logs fire simultaneously with slow/failing Telegram API calls.

Output: Patched `logging.py` with bounded thread pool, updated `network.py` with log sink timeout, and regression test.
</objective>

<execution_context>
@/Users/terryli/.claude/get-shit-done/workflows/execute-plan.md
@/Users/terryli/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@python/opendeviationbar/logging.py
@python/opendeviationbar/notify/telegram.py
@python/opendeviationbar/config/network.py
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Replace unbounded Thread with bounded ThreadPoolExecutor and add log sink timeout</name>
  <files>python/opendeviationbar/logging.py, python/opendeviationbar/config/network.py, tests/test_telegram_log_sink.py</files>
  <read_first>
    python/opendeviationbar/logging.py
    python/opendeviationbar/config/network.py
    python/opendeviationbar/notify/telegram.py
  </read_first>
  <behavior>
    - Test 1 (test_bounded_thread_pool): Monkey-patch `send_telegram` with a slow mock (sleep 2s). Fire 50 WARNING log records through `_TelegramLogSink.__call__`. Assert that `threading.active_count()` never exceeds baseline + 2 (the pool workers). Previously, 50 threads would spawn.
    - Test 2 (test_executor_submit_not_thread_start): After constructing `_TelegramLogSink`, verify its `__call__` path uses `_telegram_log_executor.submit()` and does NOT call `threading.Thread(...).start()`. Grep the source file for `threading.Thread` — must not appear in the emit/call path.
    - Test 3 (test_failed_telegram_does_not_crash_pipeline): Mock `send_telegram` to raise `ConnectionError`. Fire 10 WARNING records. Assert no exception propagates — the log pipeline continues.
    - Test 4 (test_log_sink_timeout_config): Assert `NetworkConfig().log_sink_timeout_s` exists and defaults to 3 (not 10).
  </behavior>
  <action>
    **Step 1: Add `log_sink_timeout_s` to NetworkConfig** (`python/opendeviationbar/config/network.py`):
    - Add field `log_sink_timeout_s: int = 3` after the existing `notification_timeout_s: int = 10` field (around line 62-63)
    - Add comment: `# Telegram log sink — shorter timeout to avoid blocking pool workers`

    **Step 2: Create module-level ThreadPoolExecutor** in `python/opendeviationbar/logging.py`:
    - Add `from concurrent.futures import ThreadPoolExecutor` to imports (top of file, after `import threading`)
    - Add module-level executor after the `_service_logging_initialized` line (around line 51):
      ```python
      # Bounded pool for Telegram log sink — max 2 workers prevents thread exhaustion
      # under high-volume WARNING/ERROR logging (e.g., kintsugi 30-shard SSL errors).
      # If both workers are busy, new messages queue in the executor's unbounded deque
      # which is GC'd naturally (preferable to spawning unlimited OS threads).
      _telegram_log_executor: ThreadPoolExecutor | None = None

      def _get_telegram_log_executor() -> ThreadPoolExecutor:
          global _telegram_log_executor
          if _telegram_log_executor is None:
              _telegram_log_executor = ThreadPoolExecutor(
                  max_workers=2, thread_name_prefix="telegram-log"
              )
          return _telegram_log_executor
      ```

    **Step 3: Replace `threading.Thread` in `_TelegramLogSink.__call__`** (lines 177-180):
    - Remove:
      ```python
      threading.Thread(
          target=self._send, args=(html,), daemon=True, name="telegram-log"
      ).start()
      ```
    - Replace with:
      ```python
      _get_telegram_log_executor().submit(self._send, html)
      ```

    **Step 4: Update `_TelegramLogSink._send()`** (lines 182-189):
    - Change from using `send_telegram()` with default timeout to passing `log_sink_timeout_s`:
      ```python
      @staticmethod
      def _send(html: str) -> None:
          try:
              from opendeviationbar.notify.telegram import send_telegram
              send_telegram(html, disable_notification=False, timeout=3)
          except Exception:
              pass  # Never crash the log pipeline
      ```
    - BUT: `send_telegram()` does not accept a `timeout` kwarg. Instead, temporarily override the config. Simpler approach: call `requests.post` directly with 3s timeout in `_send()`, or add `timeout` param to `send_telegram()`.
    - **Chosen approach**: Add optional `timeout: int | None = None` parameter to `send_telegram()` in `telegram.py`. When provided, use it instead of `NetworkConfig().notification_timeout_s`. This is clean and backward-compatible.

    **Step 4b: Add `timeout` parameter to `send_telegram()`** (`python/opendeviationbar/notify/telegram.py` line 143):
    - Change signature to:
      ```python
      def send_telegram(
          message: str,
          *,
          parse_mode: str = "HTML",
          disable_notification: bool = False,
          timeout: int | None = None,
      ) -> int | bool:
      ```
    - Change line 204 from:
      ```python
      response = requests.post(url, json=payload, timeout=NetworkConfig().notification_timeout_s)
      ```
      to:
      ```python
      effective_timeout = timeout if timeout is not None else NetworkConfig().notification_timeout_s
      response = requests.post(url, json=payload, timeout=effective_timeout)
      ```

    **Step 4c: Update `_TelegramLogSink._send()`** to pass the log sink timeout:
    ```python
    @staticmethod
    def _send(html: str) -> None:
        try:
            from opendeviationbar.notify.telegram import send_telegram
            from opendeviationbar.config.network import NetworkConfig
            send_telegram(html, disable_notification=False, timeout=NetworkConfig().log_sink_timeout_s)
        except Exception:
            pass  # Never crash the log pipeline
    ```

    **Step 5: Remove `import threading` usage for Thread in logging.py**: The `threading` import is still needed for `threading.Lock()` in `_TelegramLogSink.__init__`. Do NOT remove the import. Just ensure `threading.Thread` is no longer called anywhere in this file.

    **Step 6: Write tests** in `tests/test_telegram_log_sink.py` per the `<behavior>` block above.

  </action>
  <verify>
    <automated>cd /Users/terryli/eon/opendeviationbar-py && uv run --python 3.13 pytest tests/test_telegram_log_sink.py -x -v 2>&1 | tail -20</automated>
  </verify>
  <acceptance_criteria>
    - `python/opendeviationbar/logging.py` contains `ThreadPoolExecutor` import
    - `python/opendeviationbar/logging.py` contains `_get_telegram_log_executor` function
    - `python/opendeviationbar/logging.py` contains `_get_telegram_log_executor().submit(self._send, html)`
    - `python/opendeviationbar/logging.py` does NOT contain `threading.Thread(` (zero occurrences)
    - `python/opendeviationbar/config/network.py` contains `log_sink_timeout_s: int = 3`
    - `python/opendeviationbar/notify/telegram.py` contains `timeout: int | None = None` in `send_telegram` signature
    - `python/opendeviationbar/notify/telegram.py` contains `effective_timeout = timeout if timeout is not None`
    - `tests/test_telegram_log_sink.py` exists with 4 test functions
    - `uv run --python 3.13 pytest tests/test_telegram_log_sink.py -x` exits 0
  </acceptance_criteria>
  <done>
    - `threading.Thread` never called directly for Telegram log sink (STAB-01)
    - Max 2 concurrent Telegram threads regardless of log volume (STAB-01)
    - Failed Telegram threads do not crash kintsugi (STAB-02)
    - Log sink uses 3s timeout (not 10s), reducing thread hold time
    - Direct `send_telegram()` callers unaffected (default timeout unchanged at 10s)
    - All 4 regression tests pass
  </done>
</task>

</tasks>

<verification>
1. `grep -c "threading.Thread" python/opendeviationbar/logging.py` returns 0
2. `grep "ThreadPoolExecutor" python/opendeviationbar/logging.py` shows import and usage
3. `grep "log_sink_timeout_s" python/opendeviationbar/config/network.py` shows field definition
4. `uv run --python 3.13 pytest tests/test_telegram_log_sink.py -x -v` all pass
5. `uv run --python 3.13 pytest tests/ -x --timeout=120` existing test suite still passes
</verification>

<success_criteria>

- Zero `threading.Thread` calls in logging.py
- ThreadPoolExecutor with max_workers=2 handles all Telegram log dispatches
- Log sink timeout is 3s (configurable via NetworkConfig)
- Direct `send_telegram()` callers retain 10s default timeout
- All new and existing tests pass
  </success_criteria>

<output>
After completion, create `.planning/phases/01-telegram-thread-leak/01-01-SUMMARY.md`
</output>
