# Fix: Daemon push fails with SSL EOF on large payloads (#91)

## Context

Large message batches (500 messages with big `raw_data`/`content` fields from Claude Code subagents and Cursor transcripts) cause SSL EOF errors during transfer. Python's `urllib` + Azure TLS termination drops the connection mid-stream on large POST bodies. Files get stuck in an infinite retry loop with exponential backoff, never advancing state.

The core problem: `_post_batch()` in `pusher.py:72-89` encodes the full batch as JSON and sends it in one shot with no payload size awareness. With 500 messages containing large code content, payloads can exceed what the TLS layer handles reliably.

## Approach: Payload-size-aware splitting in `_post_batch`

Add a byte-size check after JSON encoding. If the payload exceeds a threshold, split the batch in half and recursively POST each half. This is robust because it handles variable message sizes (some messages are tiny, others have huge raw_data).

### Why this is safe with existing state management

1. **If split succeeds fully**: `_post_batch` returns `True`, `push()` advances normally — no change in behavior
2. **If first half succeeds, second half fails**: `_post_batch` returns `False`. The caller (`push()` at line 55-62) enqueues the **entire original serialized batch** into the retry queue. Messages from the first half get re-sent on retry, but server dedup handles this — `writer.py:118-134` uses `ON CONFLICT (id) DO UPDATE` with COALESCE, so re-sending is an idempotent upsert
3. **File state only advances for fully-successful files** (`main.py:354-371`): the `failed_at` tracking in `_poll_cycle` ensures no file state is advanced when `push()` returns `False`
4. **Retry queue drain** (`_drain_queue` at line 104-116) already calls `_post_batch`, so oversized queued batches also benefit from the split automatically

This preserves the existing **at-least-once delivery** guarantee with no new state.

### Changes

**`qc_trace/daemon/pusher.py`**

Add a module constant and modify `_post_batch`:

```python
_MAX_PAYLOAD_BYTES = 4 * 1024 * 1024  # 4 MB — well under server's 10 MB limit

def _post_batch(self, batch: list[dict]) -> bool:
    payload = json.dumps(batch).encode("utf-8")

    # Split oversized payloads to avoid SSL EOF on large transfers
    if len(payload) > _MAX_PAYLOAD_BYTES and len(batch) > 1:
        mid = len(batch) // 2
        logger.debug("Splitting oversized payload (%d bytes, %d msgs) into two batches", len(payload), len(batch))
        return self._post_batch(batch[:mid]) and self._post_batch(batch[mid:])

    # ... rest unchanged (Request creation, urlopen, error handling)
```

No other files need changes. No config change needed — this is an internal transport optimization.

### Edge cases

- **Single message > 4MB**: Sent as-is (can't split further). If it fails, it enters the normal retry/backoff flow. This is rare and acceptable — a single message that large likely has an unusually large `raw_data` field.
- **Recursion depth**: With 500 messages and binary split, max depth is ~9 (log2(500)). Safe.
- **Backoff interaction**: `_on_failure` is called once per failed `_post_batch` leaf call. If the second half of a split fails, backoff increments once — same as current behavior for a single failure.

### Tests

**`tests/test_daemon_pusher.py`** — Add to `TestPusherWithServer`:

1. **test_post_batch_splits_large_payload**: Create batch that exceeds 4MB when serialized. Verify the real HTTP server receives multiple smaller POSTs instead of one large one.
2. **test_post_batch_no_split_under_threshold**: Normal-sized batch sends single POST (regression test).
3. **test_post_batch_split_partial_failure**: Mock server to fail on second request. Verify `_post_batch` returns False and first half was still sent.
4. **test_post_batch_single_oversized_message**: Single message > 4MB is sent as-is (no infinite recursion).

## Verification

```bash
# Run pusher tests
pytest tests/test_daemon_pusher.py -v

# Run full test suite
pytest tests/

# Manual: restart daemon with a large Claude Code session file and observe logs
# Should see "Splitting oversized payload" debug messages instead of SSL EOF errors
```
