# Supplier webhook fast-fail + stuck leads cleanup Implementation Plan

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

**Goal:** Остановить retry-шторм в `failed_webhook_jobs` (256k записей от 2 застрявших supplier_leads id=1110, 1157) через (а) минимальный resolve этих 2 + (б) код-фикс на fast-fail для будущих случаев, чтобы один битый лид не генерировал >100 000 retry-записей в день.

**Architecture:** Шторм имеет два слоя:

1. **Data layer** — 2 supplier_leads с `error LIKE '%does not support%'` имеют `processed_at IS NULL` → поставщик при каждом новом webhook'е/CSV-recovery их видит как «нужно повторить» → пишет новую попытку обработки → 3 retries → 1 строка в failed_webhook_jobs. Решение: `UPDATE supplier_leads SET processed_at=NOW(), error=...` чтобы пометить как terminal-failed.
2. **Application logic layer** — `SupplierLeadRouter` (или эквивалент) при получении webhook'а проверяет `chk_supplier_projects_b1_not_for_sms` constraint и кидает Exception **без проверки** что этот же supplier_lead уже failed N раз с тем же message. Решение: short-circuit в job handler — если supplier_lead.error содержит «does not support» и нет нового события (new vid / different signal_identifier), пропускаем без retry.

**Tech Stack:** PHP 8.3 / Laravel 13, PostgreSQL 16, Pest 4, Queue jobs (Redis backend).

**Контекст находки:** docs/superpowers/plans/2026-05-29-stage5-monitoring-checklist.md → day 1 → Finding 2. Полный data dump — GitHub Actions run `26616602381` artifact `investigate-day1-round3/investigate3.log`. Распределение failed_webhook_jobs:

```
sl_id=1110 — 152 464 fails за 24h (phone 7933***4038, project «&lt;client-project&gt;.рф», B1+SMS-tag «ваши деньги»)
sl_id=1157 — 104 318 fails за 24h (тот же phone+project, source=webhook+vid)
все остальные sl_id — по 1 fail (норма)
```

Все 5 supplier_projects.platform='B1' имеют signal_type='call' — нет ни одной B1+SMS записи (constraint chk не позволяет). Поставщик crm.bp-gr.ru шлёт нам этот лид как **SMS-tag «ваши деньги»** → не находит matching supplier_project → Exception.

**Hypothesis:** Либо у поставщика legacy mapping (тэг «ваши деньги» исторически отправлялся как SMS, но клиент перешёл на call-канал в B1 без обновления mapping'а на стороне поставщика), либо это новый клиент с настройкой которая мы должны разрешить (но constraint запрещает).

**Out of scope этого плана:** разговор с поставщиком про правильный mapping. Этот план — техническая защита от подобных штормов независимо от того что поставщик пришлёт.

---

## Task 1: Manual cleanup застрявших лидов (5 минут)

**Files:**

- Create: `.github/workflows/sql-runner.yml` (универсальный SQL-runner для прод-операций)
- Create: `docs/ops/2026-05-29-stage5-stuck-leads-cleanup.md` (rollback log)

- [ ] **Step 1: SQL-runner workflow** (расширение GH Actions для произвольных whitelisted SQL запросов)

```yaml
name: Run whitelisted SQL on liderra.ru

on:
  workflow_dispatch:
    inputs:
      sql:
        description: 'SQL query (SELECT only by default; UPDATE/DELETE need confirm_mutating=true)'
        required: true
        type: string
      confirm_mutating:
        description: 'Подтверждаю UPDATE/DELETE на проде'
        required: false
        default: false
        type: boolean

jobs:
  run:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    env:
      LIDERRA_HOST: 111.88.246.137
      LIDERRA_USER: ubuntu
      SQL: ${{ github.event.inputs.sql }}
      CONFIRM_MUT: ${{ github.event.inputs.confirm_mutating }}

    steps:
      - name: Whitelist check
        run: |
          set -euo pipefail
          SQL_LOWER=$(echo "$SQL" | tr '[:upper:]' '[:lower:]' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')

          # Allow: SELECT / WITH (CTE) / \d / EXPLAIN
          READ_RE='^(select |with |explain |\\d|\\df|\\di|\\dt)'

          # Mutating allowed if confirm=true: targeted UPDATE/DELETE on specific tables
          MUTATING_RE='^(update supplier_leads|update failed_webhook_jobs|update scheduler_heartbeats|delete from failed_webhook_jobs|delete from incidents_log) '

          if [[ "$SQL_LOWER" =~ $READ_RE ]]; then
            echo "::notice::SELECT/read-only — allowed."
            exit 0
          fi

          if [[ "$SQL_LOWER" =~ $MUTATING_RE ]]; then
            if [[ "$CONFIRM_MUT" != "true" ]]; then
              echo "::error::Mutating SQL requires confirm_mutating=true."
              exit 1
            fi
            echo "::warning::Mutating SQL authorized."
            exit 0
          fi

          echo "::error::SQL not in whitelist: $SQL_LOWER"
          exit 1

      - name: Setup SSH key
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.LIDERRA_SSH_KEY }}" > ~/.ssh/liderra_deploy
          chmod 600 ~/.ssh/liderra_deploy
          ssh-keyscan -H ${{ env.LIDERRA_HOST }} >> ~/.ssh/known_hosts 2>/dev/null

      - name: Run on prod
        run: |
          set -o pipefail
          SQL_B64=$(printf '%s' "$SQL" | base64 -w0)
          ssh -i ~/.ssh/liderra_deploy ${{ env.LIDERRA_USER }}@${{ env.LIDERRA_HOST }} \
            "SQL_B64='$SQL_B64' bash -s" <<'REMOTE' | tee /tmp/sql.log
            SQL=$(echo "$SQL_B64" | base64 -d)
            echo "=== Running on $(hostname) at $(date -u) ==="
            echo "SQL: $SQL"
            echo
            sudo -u postgres psql -d liderra -c "$SQL"
            RC=$?
            echo
            echo "=== Exit code: $RC ==="
            exit $RC
          REMOTE

      - name: Summary
        if: always()
        run: |
          {
            echo "## SQL on prod"
            echo
            echo '```sql'
            echo "$SQL"
            echo '```'
            echo
            echo '```'
            cat /tmp/sql.log 2>/dev/null
            echo '```'
          } >> "$GITHUB_STEP_SUMMARY"

      - name: Cleanup
        if: always()
        run: rm -f ~/.ssh/liderra_deploy
```

- [ ] **Step 2: Snapshot before mutation**

Через `gh workflow run sql-runner.yml -f sql="SELECT id, phone, error, processed_at FROM supplier_leads WHERE id IN (1110, 1157);"` — сохранить вывод в `docs/ops/2026-05-29-stage5-stuck-leads-cleanup.md` для rollback.

- [ ] **Step 3: Resolve stuck supplier_leads**

```
gh workflow run sql-runner.yml \
  -f sql="UPDATE supplier_leads SET processed_at = NOW(), error = COALESCE(error,'') || ' [admin-resolved 2026-05-29: B1+SMS unsupported, see plan 2026-05-29-supplier-webhook-fast-fail]' WHERE id IN (1110, 1157) AND processed_at IS NULL;" \
  -f confirm_mutating=true
```

Expected: 2 rows updated.

- [ ] **Step 4: Resolve связанные failed_webhook_jobs**

```
gh workflow run sql-runner.yml \
  -f sql="UPDATE failed_webhook_jobs SET resolved_at = NOW(), retried_by = 'admin-cleanup-2026-05-29' WHERE raw_payload->>'supplier_lead_id' IN ('1110','1157') AND resolved_at IS NULL;" \
  -f confirm_mutating=true
```

Expected: ~256 000 rows updated.

- [ ] **Step 5: Verify**

```
gh workflow run sql-runner.yml \
  -f sql="SELECT COUNT(*) FROM failed_webhook_jobs WHERE failed_at > NOW() - INTERVAL '1 hour' AND resolved_at IS NULL;"
```

Через 1 час после Step 4: ожидаем count <100 (если поставщик продолжает слать те же 2 лида — будут новые попытки, Task 2 это закроет).

- [ ] **Step 6: Commit + ops log**

```
git add .github/workflows/sql-runner.yml docs/ops/2026-05-29-stage5-stuck-leads-cleanup.md
git commit -m "ops: cleanup 2 stuck supplier_leads (1110, 1157) + 256k failed_webhook_jobs (manual resolve, root cause: poставщик шлёт B1+SMS combo, constraint chk запрещает; Task 2 plan — fast-fail в job handler чтобы будущие подобные кейсы не нагнетали)"
```

---

## Task 2: Fast-fail в SupplierWebhookJob

**Files:**

- Locate: `app/app/Jobs/Supplier/ProcessSupplierWebhookJob.php` (или эквивалент — нужно найти конкретный класс)
- Modify: добавить short-circuit в начале `handle()`
- Test: `app/tests/Feature/Supplier/SupplierWebhookFastFailTest.php`

- [ ] **Step 1: Locate the actual job class**

```
cd /var/www/liderra/app
grep -rln "failed_webhook_jobs" app/ | head -5
grep -rln "does not support SMS" app/ | head -5
```

Найти PHP файл где Exception про «B1 does not support SMS» бросается. Записать точный путь.

- [ ] **Step 2: Failing test**

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Supplier;

use App\Jobs\Supplier\ProcessSupplierWebhookJob; // или реальное имя
use App\Models\SupplierLead;
use App\Models\Tenant;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;

it('webhook job fast-fails when supplier_lead already errored with does-not-support', function (): void {
    $tenant = Tenant::factory()->create();
    DB::statement("SET LOCAL app.current_tenant_id = " . $tenant->id);

    $lead = SupplierLead::factory()->create([
        'platform' => 'B1',
        'phone' => '7933***4038',
        'raw_payload' => ['tag' => 'ваши деньги', 'project' => 'B1_&lt;client-project&gt;.рф'],
        'error' => 'B1 platform does not support SMS signals (supplier limitation: chk_supplier_projects_b1_not_for_sms)',
        'processed_at' => null,
    ]);

    $startFails = DB::table('failed_webhook_jobs')->count();

    // Dispatch handler synchronously — should fast-fail without writing failed_webhook_jobs
    $job = new ProcessSupplierWebhookJob($lead->id);
    $job->handle();

    $endFails = DB::table('failed_webhook_jobs')->count();
    expect($endFails)->toBe($startFails); // 0 new failures
    expect($lead->fresh()->processed_at)->not->toBeNull(); // marked processed
});

it('webhook job processes lead normally when error is empty or transient', function (): void {
    $tenant = Tenant::factory()->create();
    DB::statement("SET LOCAL app.current_tenant_id = " . $tenant->id);

    $lead = SupplierLead::factory()->create([
        'platform' => 'B1',
        'error' => null,
        'processed_at' => null,
    ]);

    $job = new ProcessSupplierWebhookJob($lead->id);
    // Этот тест может бросить (нет matching project) — главное что fast-fail НЕ срабатывает
    try { $job->handle(); } catch (\Throwable) {}

    // Lead либо processed (matched), либо вернулся в queue — но fast-fail terminal mark не произошёл
    $fresh = $lead->fresh();
    $isFastFailed = $fresh->processed_at !== null && str_contains($fresh->error ?? '', 'fast-failed');
    expect($isFastFailed)->toBeFalse();
});
```

- [ ] **Step 3: Run failing test**

Expected: FAIL — fast-fail логики нет, обработчик идёт по обычному пути, ловит Exception, пишет в failed_webhook_jobs.

- [ ] **Step 4: Implementation — short-circuit в handle()**

В найденном на Step 1 классе (предположим `ProcessSupplierWebhookJob`):

```php
public function handle(): void
{
    $lead = SupplierLead::find($this->supplierLeadId);
    if ($lead === null) {
        return;
    }

    // Fast-fail: if lead already failed with a terminal error and no new
    // distinguishing data arrived, mark processed and exit.
    // Closes failed_webhook_jobs storm class (Finding 2 plan).
    $isTerminal = $lead->error !== null && (
        str_contains($lead->error, 'does not support')
        || str_contains($lead->error, 'platform mismatch')
        || str_contains($lead->error, 'no matching supplier_project')
    );
    if ($isTerminal && $lead->processed_at === null) {
        $lead->update([
            'processed_at' => now(),
            'error' => $lead->error . ' [fast-failed by ProcessSupplierWebhookJob]',
        ]);
        return;
    }

    // Existing normal flow...
}
```

- [ ] **Step 5: Run test, expect PASS**

Expected: PASS — fast-fail path активируется когда lead.error содержит terminal-pattern и processed_at IS NULL → НЕ пишет в failed_webhook_jobs → marks processed.

- [ ] **Step 6: Commit**

```
git add app/app/Jobs/Supplier/ProcessSupplierWebhookJob.php app/tests/Feature/Supplier/SupplierWebhookFastFailTest.php
git commit -m "feat(supplier): fast-fail in webhook job for terminal errors (closes 256k failed_webhook_jobs storm class — if supplier_lead.error contains 'does not support'/'platform mismatch'/'no matching supplier_project' and processed_at IS NULL, mark processed and exit without writing failed_webhook_jobs)"
```

---

## Task 3: Метрика и алерт на новый шторм

**Files:**

- Modify: `app/app/Console/Commands/IncidentsWatchFailures.php` (порог per-supplier_lead_id)

- [ ] **Step 1: Failing test**

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Incidents;

use Illuminate\Support\Facades\DB;
use Tests\TestCase;

it('watch-failures detects single-lead storm (>=1000 failures per supplier_lead_id in 1h)', function (): void {
    // Insert 1500 failed_webhook_jobs for the same sl_id
    $rows = [];
    for ($i = 0; $i < 1500; $i++) {
        $rows[] = [
            'raw_payload' => json_encode(['supplier_lead_id' => 9999]),
            'exception' => 'Test storm',
            'retry_count' => 3,
            'failed_at' => now()->subMinutes(rand(1, 30)),
        ];
    }
    DB::connection('pgsql_supplier')->table('failed_webhook_jobs')->insert($rows);

    $exitCode = $this->artisan('incidents:watch-failures')->run();
    expect($exitCode)->toBe(0); // command succeeds

    // Incident logged for the storm
    $incident = DB::table('incidents_log')
        ->where('root_cause', 'LIKE', '%single-lead-storm%')
        ->orderByDesc('created_at')
        ->first();
    expect($incident)->not->toBeNull();
    expect($incident->severity)->toBe('high');
});
```

- [ ] **Step 2-5: Implement single-lead-storm detection** в `incidents:watch-failures` (новый порог `--threshold-single-lead=1000`)

- [ ] **Step 6: Commit**

---

## Task 4: Deploy + verify

> **NB (2026-05-29):** Task 2 — PHP-код fast-fail в RouteSupplierLeadJob.
> Task 3 — опция --threshold-single-lead в incidents:watch-failures.
> **Миграций нет** — только PHP. Деплой активирует fast-fail немедленно.

- [ ] **Step 1: Merge + деплой**

```bash
# Controller merges agent branch to main, then:
gh workflow run deploy.yml --ref main
```

- [ ] **Step 2: Manual cleanup застрявших лидов (Task 1 Steps 3-4)**

```bash
# UPDATE 2 stuck supplier_leads (processed_at IS NULL)
gh workflow run sql-runner.yml \
  -f sql="UPDATE supplier_leads SET processed_at = NOW(), error = COALESCE(error,'') || ' [admin-resolved 2026-05-29: B1+SMS unsupported, see plan 2026-05-29-supplier-webhook-fast-fail]' WHERE id IN (1110, 1157) AND processed_at IS NULL;" \
  -f confirm_mutating=true

# UPDATE ~256k failed_webhook_jobs (mark resolved)
gh workflow run sql-runner.yml \
  -f sql="UPDATE failed_webhook_jobs SET resolved_at = NOW(), retried_by = 'admin-cleanup-2026-05-29' WHERE raw_payload->>'supplier_lead_id' IN ('1110','1157') AND resolved_at IS NULL;" \
  -f confirm_mutating=true
```

  Заполнить лог в `docs/ops/2026-05-29-stage5-stuck-leads-cleanup.md`.

- [ ] **Step 3: Verify storm stopped (через 1 час после Step 2)**

```bash
gh workflow run sql-runner.yml \
  -f sql="SELECT COUNT(*) FROM failed_webhook_jobs WHERE failed_at > NOW() - INTERVAL '1 hour' AND resolved_at IS NULL;"
```

  Expected: < 100. Если поставщик шлёт те же лиды повторно:
  - 1-й webhook → новый SupplierLead (error=NULL) → ≤3 retries → failed() записывает error
  - Следующий retry (RetryFailedSupplierJobsCommand) → fast-fail срабатывает → processed
  - Net: ≤3 строки failed_webhook_jobs вместо 256k.

- [ ] **Step 4: Verify incidents:watch-failures алерт**

```bash
gh workflow run sql-runner.yml \
  -f sql="SELECT root_cause, severity, detected_at FROM incidents_log WHERE root_cause LIKE '%single-lead-storm%' ORDER BY detected_at DESC LIMIT 5;"
```

- [ ] **Step 5: Memory update**
  - `project_state.md` — запись о Task 2+3+4 closure
  - новый `memory/feedback_supplier_webhook_fast_fail.md` — паттерн + поведение шторма

---

## Self-review

**Spec coverage:**

- ✅ Остановить шторм на 1110/1157 → Task 1 manual resolve.
- ✅ Запретить будущим подобным лидам нагнетать → Task 2 fast-fail.
- ✅ Алерт если новый шторм всё-таки случится → Task 3 single-lead-storm threshold.
- ⚠️ Out of scope: связаться с поставщиком про правильный mapping B1+«ваши деньги» — это бизнес-задача, не код.

**Risks/edge cases:**

- Task 2 фaст-fail срабатывает на любую ошибку содержащую «does not support» / «platform mismatch» / «no matching supplier_project». **Риск:** если эти patterns появятся transient'ом (например БД временно недоступна → пишет такую ошибку, лид кажется terminal'ным), мы ошибочно пометим processed. Mitigation: список patterns короткий и специфичный, для transient ошибок используется retry с другими error message.
- Task 1 Step 4 update 256k rows — может занять несколько минут на проде. БД 16 GB, table small column count. Verify perf на dev перед prod.
- Task 1 SQL-runner whitelist разрешает UPDATE/DELETE на 5 конкретных таблиц с confirm. Перед merge'ом — review кем именно из админ-аккаунтов GH Actions можно запускать workflow_dispatch (`Settings → Actions → Workflow permissions`).

**Что НЕ покрыто (out of scope):**

- Структурная проблема «constraint vs supplier mapping» — нужно решение от бизнес-сторон (либо снять constraint chk и позволить B1+SMS, либо договориться с поставщиком о mapping). Этот план — техническая защита, не бизнес-решение.
